yukinovel
Version:
Yukinovel is a simple web visual novel engine.
306 lines (305 loc) • 11.3 kB
JavaScript
export class ScriptingEngine {
constructor(game) {
this.variables = new Map();
this.functions = new Map();
this.stack = []; // Call stack
this.game = game;
this.initializeBuiltinFunctions();
}
// Khởi tạo các hàm built-in
initializeBuiltinFunctions() {
this.functions.set('random', (min, max) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
});
this.functions.set('choice', (choices) => {
return choices[Math.floor(Math.random() * choices.length)];
});
this.functions.set('time', () => {
return new Date().toISOString();
});
this.functions.set('dayOfWeek', () => {
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
return days[new Date().getDay()];
});
this.functions.set('formatText', (text, ...args) => {
return text.replace(/{(\d+)}/g, (match, index) => {
return args[index] !== undefined ? args[index] : match;
});
});
this.functions.set('lerp', (start, end, t) => {
return start + (end - start) * Math.max(0, Math.min(1, t));
});
this.functions.set('distance', (x1, y1, x2, y2) => {
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
});
}
// Set variable
setVariable(name, value, persistent = false) {
const type = this.getValueType(value);
const variable = {
name,
value,
type,
persistent
};
this.variables.set(name, variable);
// Trigger event
this.game.emit('variableChanged', { name, value, type });
}
// Get variable
getVariable(name) {
const variable = this.variables.get(name);
return variable ? variable.value : undefined;
}
// Check if variable exists
hasVariable(name) {
return this.variables.has(name);
}
// Delete variable
deleteVariable(name) {
return this.variables.delete(name);
}
// Get all variables
getAllVariables() {
return Array.from(this.variables.values());
}
// Get persistent variables (for saving)
getPersistentVariables() {
return Array.from(this.variables.values()).filter(v => v.persistent);
}
// Load persistent variables (from save)
loadPersistentVariables(variables) {
variables.forEach(variable => {
if (variable.persistent) {
this.variables.set(variable.name, variable);
}
});
}
// Evaluate expression
evaluateExpression(expression) {
// Simple expression parser
// Supports variables, functions, basic math, and string operations
try {
// Replace variables
let processedExpression = expression.replace(/\$(\w+)/g, (match, varName) => {
const value = this.getVariable(varName);
return JSON.stringify(value);
});
// Replace function calls
processedExpression = processedExpression.replace(/(\w+)\((.*?)\)/g, (match, funcName, args) => {
const func = this.functions.get(funcName);
if (func) {
try {
const argValues = args ? args.split(',').map((arg) => {
return this.evaluateExpression(arg.trim());
}) : [];
return JSON.stringify(func(...argValues));
}
catch (e) {
console.error(`Error calling function ${funcName}:`, e);
return 'null';
}
}
return match;
});
// Safe evaluation (limited to basic operations)
return this.safeEval(processedExpression);
}
catch (error) {
console.error('Error evaluating expression:', expression, error);
return null;
}
}
// Safe evaluation of expressions
safeEval(expression) {
// Whitelist of allowed operations
const allowedOperators = ['+', '-', '*', '/', '%', '==', '!=', '>', '<', '>=', '<=', '&&', '||', '!'];
const allowedFunctions = ['Math.floor', 'Math.ceil', 'Math.round', 'Math.abs', 'Math.min', 'Math.max'];
// Remove potentially dangerous code
const sanitized = expression
.replace(/[^a-zA-Z0-9\s+\-*/%()=!<>&|.,'"]/g, '')
.replace(/eval|function|Function|setTimeout|setInterval|require|import|export/gi, '');
try {
return Function(`"use strict"; return (${sanitized})`)();
}
catch (error) {
console.error('Safe eval error:', error);
return null;
}
}
// Check conditions
checkConditions(conditions) {
if (!conditions || conditions.length === 0)
return true;
let result = true;
let currentLogic = 'and';
for (const condition of conditions) {
const variableValue = this.getVariable(condition.variable);
const conditionResult = this.evaluateCondition(variableValue, condition.operator, condition.value);
if (currentLogic === 'and') {
result = result && conditionResult;
}
else {
result = result || conditionResult;
}
currentLogic = condition.logic || 'and';
}
return result;
}
// Evaluate single condition
evaluateCondition(leftValue, operator, rightValue) {
switch (operator) {
case '==':
return leftValue == rightValue;
case '!=':
return leftValue != rightValue;
case '>':
return Number(leftValue) > Number(rightValue);
case '<':
return Number(leftValue) < Number(rightValue);
case '>=':
return Number(leftValue) >= Number(rightValue);
case '<=':
return Number(leftValue) <= Number(rightValue);
case 'contains':
return String(leftValue).includes(String(rightValue));
case 'in':
return Array.isArray(leftValue) && leftValue.includes(rightValue);
default:
return false;
}
}
// Execute script commands
async executeScript(commands) {
for (const command of commands) {
await this.executeCommand(command);
}
}
// Execute single command
async executeCommand(command) {
// Check conditions if any
if (command.conditions && !this.checkConditions(command.conditions)) {
if (command.onFalse) {
await this.executeScript(command.onFalse);
}
return;
}
switch (command.type) {
case 'set':
this.setVariable(command.params.name, this.evaluateExpression(command.params.value), command.params.persistent);
break;
case 'if':
if (command.onTrue) {
await this.executeScript(command.onTrue);
}
break;
case 'jump':
this.game.jumpToScene(command.params.scene, command.params.dialogue);
break;
case 'call':
const func = this.functions.get(command.params.function);
if (func) {
const args = command.params.args || [];
const result = func(...args);
if (command.params.returnVar) {
this.setVariable(command.params.returnVar, result);
}
}
break;
case 'wait':
await new Promise(resolve => setTimeout(resolve, command.params.duration || 1000));
break;
case 'custom':
// Execute custom command through plugin system
if (this.game.pluginManager) {
this.game.pluginManager.executeAction(command.params.action, command.params);
}
break;
}
}
// Register custom function
registerFunction(name, func) {
this.functions.set(name, func);
}
// Unregister function
unregisterFunction(name) {
return this.functions.delete(name);
}
// Get value type
getValueType(value) {
if (typeof value === 'string')
return 'string';
if (typeof value === 'number')
return 'number';
if (typeof value === 'boolean')
return 'boolean';
if (Array.isArray(value))
return 'array';
if (typeof value === 'object')
return 'object';
return 'string'; // default
}
// Process text with variables and expressions
processText(text) {
// Replace variables: $variableName
let processed = text.replace(/\$(\w+)/g, (match, varName) => {
const value = this.getVariable(varName);
return value !== undefined ? String(value) : match;
});
// Replace expressions: {expression}
processed = processed.replace(/\{([^}]+)\}/g, (match, expression) => {
const result = this.evaluateExpression(expression);
return result !== null ? String(result) : match;
});
return processed;
}
// Math utilities
increment(variableName, amount = 1) {
const current = Number(this.getVariable(variableName)) || 0;
this.setVariable(variableName, current + amount);
}
decrement(variableName, amount = 1) {
const current = Number(this.getVariable(variableName)) || 0;
this.setVariable(variableName, current - amount);
}
// Array utilities
pushToArray(variableName, value) {
const array = this.getVariable(variableName) || [];
if (Array.isArray(array)) {
array.push(value);
this.setVariable(variableName, array);
}
}
popFromArray(variableName) {
const array = this.getVariable(variableName);
if (Array.isArray(array)) {
const value = array.pop();
this.setVariable(variableName, array);
return value;
}
return null;
}
// String utilities
concatenateStrings(variableName, value) {
const current = String(this.getVariable(variableName) || '');
this.setVariable(variableName, current + value);
}
// Clear all non-persistent variables
clearTemporaryVariables() {
const persistentVars = new Map();
this.variables.forEach((variable, name) => {
if (variable.persistent) {
persistentVars.set(name, variable);
}
});
this.variables = persistentVars;
}
// Export state for debugging
exportState() {
return {
variables: Array.from(this.variables.entries()),
functions: Array.from(this.functions.keys()),
stack: [...this.stack]
};
}
}