diff --git a/scratch-gui/package.json b/scratch-gui/package.json index 83921260..ae48d09a 100644 --- a/scratch-gui/package.json +++ b/scratch-gui/package.json @@ -28,6 +28,8 @@ "test:lint": "eslint . --ext .js,.jsx", "test:unit": "jest test[\\\\/]unit[\\\\/]addons", "test:smoke": "jest --runInBand test[\\\\/]smoke", + "test:cpp": "node scripts/test-cpp-regressions.cjs", + "test:devtool": "node scripts/test-devtool-control.cjs", "watch": "webpack --colors --watch" }, "config": { diff --git a/scratch-gui/scripts/diagnose-cpp.cjs b/scratch-gui/scripts/diagnose-cpp.cjs new file mode 100644 index 00000000..f4dd0003 --- /dev/null +++ b/scratch-gui/scripts/diagnose-cpp.cjs @@ -0,0 +1,71 @@ +// Read-only diagnostic: exercise the current editor interpreter with mocked game APIs. +// Run from scratch-gui: node scripts/diagnose-cpp.cjs +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const babel = require('@babel/core'); +const filename = path.resolve(__dirname, '../src/components/stage-cpp/cpp-parser.js'); +const transformed = babel.transformSync(fs.readFileSync(filename, 'utf8'), { + filename, configFile: false, babelrc: false, + plugins: ['@babel/plugin-transform-modules-commonjs'] +}).code; +const context = { + exports: {}, require, setTimeout, + console: {log() {}, warn() {}, error() {}}, window: {} +}; +vm.runInNewContext(transformed, context, {filename}); +const {CppCodeExecutor, validateCppSyntax} = context.exports; +const main = body => `int main() {\n${body}\n}`; +const customer = `void test() { + for (int i = 0; i < 2; i++) { + } +} +int main() { + turnLeft(-1); + for (int i = 0; i < 1; i++) { + test(); + playerMove(i); + } +}`; +const cases = [ + ['customer_local_i', customer, {moves: [0]}], + ['customer_renamed_inner_j', customer.replace('int i = 0; i < 2; i++', 'int j = 0; j < 2; j++'), {moves: [0]}], + ['cout_literal', main('cout << "Hello" << endl;'), {output: ['Hello\n']}], + ['cout_variable', main('int i = 7;\ncout << "i=" << i << endl;'), {output: ['i=7\n']}], + ['cout_string_assignment_shape', main('cout << "value=42" << endl;'), {output: ['value=42\n']}], + ['cout_increment_expression', main('int i = 0;\ncout << i++ << endl;\nplayerMove(i);'), {output: ['0\n'], moves: [1]}], + ['cout_increment_literal', main('cout << "C++" << endl;'), {output: ['C++\n']}], + ['cout_std_qualified', main('std::cout << "Hello" << std::endl;'), {output: ['Hello\n']}], + ['main_return_zero', main('return 0;'), {error: null}], + ['legal_space_before_call', main('playerMove (1);'), {valid: true, moves: [1]}], + ['nested_loop_shadowing', main('for (int i = 0; i < 2; i++) {\nfor (int i = 0; i < 2; i++) {\n}\nplayerMove(i);\n}'), {moves: [0, 1]}], + ['negative_integer_division', main('int a = -3;\na /= 2;\nplayerMove(a);'), {moves: [-1]}], + ['function_return_value', 'int answer() {\nreturn 7;\n}\n' + main('int n = answer();\nplayerMove(n);'), {moves: [7]}], + ['nested_if_break', main('for (int i = 0; i < 3; i++) {\nif (i == 1) {\nbreak;\n}\nplayerMove(i);\n}'), {moves: [0]}], + ['floating_point', main('double x = 1.5;\ncout << x << endl;'), {output: ['1.5\n']}], + ['array_index', main('int a[2] = {3, 7};\nplayerMove(a[1]);'), {moves: [7]}], + ['unknown_variable', main('playerMove(missing);'), {valid: false}], + ['early_return_zero', 'int stop() {\nreturn 0;\nplayerMove(9);\n}\n' + main('stop();'), {moves: []}] +]; +(async () => { + const results = []; + for (const [name, code, expected] of cases) { + const actual = {valid: false, moves: [], turns: [], output: [], error: null}; + context.window = {isScriptRunOver: false, cppGameAPI: { + playerMove: async n => actual.moves.push(n), + turnLeft: async n => actual.turns.push(n), + cmdPrint: text => actual.output.push(text) + }}; + const validation = await validateCppSyntax(code); + actual.valid = validation.isValid; + if (!actual.valid) actual.validationErrors = validation.errors; + const executor = new CppCodeExecutor(code, null); + if (actual.valid) { + try { await executor.run(); } catch (error) {actual.error = error.message;} + } + const matchesExpected = Object.entries(expected).every(([key, value]) => + JSON.stringify(actual[key]) === JSON.stringify(value)); + results.push({name, expected, actual, matchesExpected}); + } + process.stdout.write(JSON.stringify(results, null, 2) + '\n'); +})().catch(error => {console.error(error); process.exitCode = 1;}); diff --git a/scratch-gui/scripts/test-cpp-regressions.cjs b/scratch-gui/scripts/test-cpp-regressions.cjs new file mode 100644 index 00000000..fc5ed6e5 --- /dev/null +++ b/scratch-gui/scripts/test-cpp-regressions.cjs @@ -0,0 +1,162 @@ +// Regression tests for the editor's real interpreter and output callback. +// Run: node scripts/test-cpp-regressions.cjs +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const babel = require('@babel/core'); +const parserPath = path.resolve(__dirname, '../src/components/stage-cpp/cpp-parser.js'); +const source = fs.readFileSync(parserPath, 'utf8'); +const compiled = babel.transformSync(source, { + filename: parserPath, configFile: false, babelrc: false, + plugins: ['@babel/plugin-transform-modules-commonjs'], + presets: process.argv.includes('--transpiled') ? ['@babel/preset-env'] : [] +}).code; +const silentConsole = {log() {}, warn() {}, error() {}}; +const context = {exports: {}, require, setTimeout, console: silentConsole, window: {}}; +vm.runInNewContext(compiled, context, {filename: parserPath}); +const {CppCodeExecutor, validateCppSyntax} = context.exports; +const main = body => `int main() {\n${body}\n}`; +// Complete program from the first email screenshot; the second repeats this issue. +const customerEmailCode = `void test() { + for (int i = 0; i < 2; i++) { + } +} + +int main() { + // 在这里编写你的C++代码来控制角色 + turnLeft(-1); + for (int i = 0; i < 1; i++) { + test(); + playerMove(i); + } + return 0; +}`; +// The third screenshot reports cout without supplying a cout example. +// Add observable output to the supplied program to check both fixes together. +const customerEmailWithOutput = customerEmailCode.replace(' playerMove(i);', + ' cout << "i=" << i << endl;\n playerMove(i);'); + +async function run(code, expected, configure) { + const moves = []; + const turns = []; + const output = []; + context.window = {isScriptRunOver: false, cppGameAPI: { + playerMove: async n => moves.push(n), + turnLeft: async n => turns.push(n), + cmdPrint: text => output.push(text) + }}; + const executor = new CppCodeExecutor(code, null); + if (configure) configure(executor, context.window); + const validation = await validateCppSyntax(code); + assert.strictEqual(validation.isValid, true, JSON.stringify(validation.errors)); + await executor.run(); + if (expected.moves) assert.deepStrictEqual(moves, expected.moves); + if (expected.turns) assert.deepStrictEqual(turns, expected.turns); + if (expected.output) assert.deepStrictEqual(output, expected.output); + assert.strictEqual(executor.variables, executor.globalVariables, 'scope must unwind after execution'); + assert.strictEqual(executor.callStack.length, 0); + return executor; +} + +const cases = [ + ['customer email complete program (screenshots 1 and 2)', customerEmailCode, {moves: [0], turns: [-1]}], + ['customer email with variable output (screenshot 3)', customerEmailWithOutput, {moves: [0], turns: [-1], output: ['i=0\n']}], + ['nested loops', main('for (int i = 0; i < 2; i++) {\nfor (int i = 0; i < 2; i++) {\n}\nplayerMove(i);\n}'), {moves: [0, 1]}], + ['loop initializer restores shadowed value', main('int i = 9;\nfor (int i = 0; i < 2; i++) {\n}\nplayerMove(i);'), {moves: [9]}], + ['loop assignment updates existing value', main('int i = 9;\nfor (i = 0; i < 2; i++) {\n}\nplayerMove(i);'), {moves: [2]}], + ['block declaration versus assignment', main('int n = 1;\nif (true) {\nint n = 7;\nplayerMove(n);\n}\nif (true) {\nn = 3;\n}\nplayerMove(n);'), {moves: [7, 3]}], + ['string block shadowing', main('string msg = "outer";\nif (true) {\nstring msg = "C++";\ncout << msg;\n}\ncout << msg;'), {output: ['C++', 'outer']}], + ['for body scope per iteration', main('int x = 9;\nfor (int i = 0; i < 2; i++) {\nplayerMove(x);\nint x = 2;\n}\nplayerMove(x);'), {moves: [9, 9, 9]}], + ['while body scope per iteration', main('int x = 9;\nint i = 0;\nwhile (i < 2) {\nplayerMove(x);\nint x = 2;\ni++;\n}\nplayerMove(x);'), {moves: [9, 9, 9]}], + ['function locals', 'void f() {\nint n = 7;\nplayerMove(n);\n}\n' + main('int n = 2;\nf();\nplayerMove(n);'), {moves: [7, 2]}], + ['all arguments evaluated before binding', 'void f(int a, int b) {\nplayerMove(a);\nplayerMove(b);\n}\n' + main('int a = 2;\nf(7, a);\nplayerMove(a);'), {moves: [7, 2, 2]}], + ['recursive locals and parameters', 'void f(int n) {\nint saved = n;\nif (n > 0) {\nf(n - 1);\n}\nplayerMove(saved);\n}\n' + main('f(2);'), {moves: [0, 1, 2]}], + ['early void return unwinds scope', 'void f() {\nint n = 8;\nreturn;\n}\n' + main('int n = 2;\nf();\nplayerMove(n);'), {moves: [2]}], + ['continue unwinds body scope', main('int x = 9;\nfor (int i = 0; i < 2; i++) {\nint x = 2;\ncontinue;\n}\nplayerMove(x);'), {moves: [9]}], + ['break does not increment', main('int i = 0;\nfor (i = 0; i < 2; i++) {\nbreak;\n}\nplayerMove(i);'), {moves: [0]}], + ['cout with equals', main('int i = 7;\ncout << "i=" << i << endl;'), {output: ['i=7\n']}], + ['cout with operator text', main('cout << "value=42 C++ -- +=" << endl;'), {output: ['value=42 C++ -- +=\n']}], + ['cout with game API text', main('cout << "playerMove(2)" << endl;'), {moves: [], output: ['playerMove(2)\n']}], + ['qualified cout', main('std::cout << "Hello" << std::endl;'), {output: ['Hello\n']}], + ['cout postfix and prefix', main('int i = 0;\ncout << i++ << endl;\ncout << ++i << endl;\ncout << i-- << endl;\ncout << --i << endl;'), {output: ['0\n', '2\n', '2\n', '0\n']}], + ['cout arithmetic', main('int i = 3;\ncout << "result=" << (i * 2 + 1) << endl;'), {output: ['result=7\n']}], + ['escaped quotes and stream delimiters', main(String.raw`cout << "a\"< { + for (const [name, code, expected] of cases) { + try { await run(code, expected); } catch (error) {error.message = `${name}: ${error.message}`; throw error;} + console.log(`PASS ${name}`); + } + // Seed the interpreter's global environment to verify lexical lookup and writes. + const globalExecutor = await run('void f() {\ng += 1;\nplayerMove(g);\n}\n' + main('int g = 9;\nf();\nplayerMove(g);'), {moves: [2, 9]}, executor => executor.variables.set('g', 1)); + assert.strictEqual(globalExecutor.variables.get('g'), 2); + console.log('PASS global writes survive function calls; caller locals are not visible'); + + for (const stop of [false, true]) { + const executor = new CppCodeExecutor('void f(int n) {\nfor (int i = 0; i < 2; i++) {\nplayerMove(i);\n}\n}\n' + main('f(2);'), null); + executor.variables.set('n', 9); + context.window = {isScriptRunOver: false, cppGameAPI: {playerMove: async () => { + if (stop) context.window.isScriptRunOver = true; + else throw new Error('game failure'); + }}}; + if (stop) await executor.run(); + else await assert.rejects(executor.run(), /game failure/); + assert.strictEqual(executor.variables, executor.globalVariables); + assert.strictEqual(executor.variables.get('n'), 9); + assert.strictEqual(executor.variables.has('i'), false); + assert.strictEqual(executor.callStack.length, 0); + assert.strictEqual(executor.currentExecutionContext, null); + console.log(`PASS scope cleanup on ${stop ? 'stop' : 'error'}`); + } + + // Extract the actual React component callback, avoiding a game-engine dependency. + const uiPath = path.resolve(__dirname, '../src/components/stage-cpp/stage-cpp-clang.jsx'); + const uiSource = fs.readFileSync(uiPath, 'utf8'); + const ast = babel.parseSync(uiSource, {configFile: false, babelrc: false, parserOpts: {plugins: ['jsx']}}); + let callback; + function visit(node) { + if (!node || typeof node !== 'object') return; + if (node.type === 'ObjectProperty' && node.key.name === 'cmdPrint') callback = node.value; + for (const value of Object.values(node)) { + if (Array.isArray(value)) value.forEach(visit); + else if (value && typeof value === 'object') visit(value); + } + } + visit(ast); + assert(callback, 'cmdPrint callback exists'); + for (const engineState of ['absent', 'ready', 'throwing']) { + let displayed = ''; + const sent = []; + const window = {}; + if (engineState !== 'absent') window.unityInstance = {SendMessage: (...args) => { + if (engineState === 'throwing') throw new Error('engine unavailable'); + sent.push(args); + }}; + const cmdPrint = vm.runInNewContext(`(${uiSource.slice(callback.start, callback.end)})`, { + window, console: silentConsole, setCompilationOutput: update => {displayed = update(displayed);} + }); + cmdPrint('A'); + cmdPrint('B\n'); + assert.strictEqual(displayed, 'AB\n'); + assert.strictEqual(window.cmd_print_text, 'AB\n'); + if (engineState === 'ready') assert.deepStrictEqual(sent[1], ['UIMain', 'SetText', 'AB\n']); + console.log(`PASS output callback with ${engineState} engine`); + + // Connect the real interpreter to the real UI callback, including repeated runs. + for (let attempt = 0; attempt < 2; attempt++) { + displayed = ''; + window.cmd_print_text = ''; + await run(customerEmailWithOutput, {moves: [0], turns: [-1]}, (executor, runtimeWindow) => { + runtimeWindow.cppGameAPI.cmdPrint = cmdPrint; + }); + assert.strictEqual(displayed, 'i=0\n'); + assert.strictEqual(window.cmd_print_text, 'i=0\n'); + if (engineState === 'ready') assert.deepStrictEqual(sent[sent.length - 1], ['UIMain', 'SetText', 'i=0\n']); + } + console.log(`PASS customer program through output callback with ${engineState} engine`); + } + console.log(`${cases.length + 9} regression checks passed`); +})().catch(error => {console.error(error); process.exitCode = 1;}); diff --git a/scratch-gui/scripts/test-devtool-control.cjs b/scratch-gui/scripts/test-devtool-control.cjs new file mode 100644 index 00000000..5277e178 --- /dev/null +++ b/scratch-gui/scripts/test-devtool-control.cjs @@ -0,0 +1,166 @@ +// Run: npm run test:devtool +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const babel = require('@babel/core'); +const root = path.resolve(__dirname, '..'); +const controlPath = 'src/playground/devtool-control.js'; +const compile = file => babel.transformFileSync(path.join(root, file), { + configFile: false, babelrc: false, presets: ['@babel/preset-env', '@babel/preset-react'] +}).code; +const controlCode = compile(controlPath); +const homeCode = compile('src/playground/home.js'); +let checks = 0; +function test(name, action) { + action(); + checks++; + console.log(`PASS ${name}`); +} + +function page(storage = new Map(), {enabled = true, brokenStorage = false, immediate = false} = {}) { + const starts = []; + const effects = []; + function detector(options) { + starts.push(options); + // Simulate a library probe firing during initialization with F12 already open. + if (immediate) options.ondevtoolopen('already-open'); + } + detector.isSuspend = false; + const window = { + location: {href: '/editor.html'}, onbeforeunload: () => {}, + cppCodeStopRun: () => effects.push('stop C++'), + loadCppProject: () => effects.push('clear C++'), + pythonCodeStopRun: () => effects.push('stop Python'), + loadPythonProject: () => effects.push('clear Python'), + unityInstance: {Quit: () => effects.push('quit engine')} + }; + const sessionStorage = { + getItem(key) {if (brokenStorage) throw new Error('storage denied'); return storage.get(key) || null;}, + setItem(key, value) {if (brokenStorage) throw new Error('storage denied'); storage.set(key, value);} + }; + const sandbox = { + exports: {}, window, sessionStorage, + localStorage: {getItem: () => '/index.html'}, + vm: { + stopAll: () => effects.push('stop Scratch'), + editingTarget: {blocks: {deleteAllBlocks: () => effects.push('clear Scratch')}}, + emitWorkspaceUpdate: () => effects.push('update workspace') + }, + require(name) { + if (name === 'disable-devtool') return detector; + if (name === './config.js') return {disabledev: enabled}; + if (name === './devtool-control.js') return api; + throw new Error(`Unexpected import: ${name}`); + } + }; + const context = vm.createContext(sandbox); + vm.runInContext(controlCode, context, {filename: controlPath}); + const api = sandbox.exports; + return {api, starts, effects, detector, window, context}; +} + +// Use the actual options and destructive callback from each editor mode. +function stageOptions(file) { + const source = fs.readFileSync(path.join(root, file), 'utf8'); + const ast = babel.parseSync(source, {configFile: false, babelrc: false, parserOpts: {plugins: ['jsx']}}); + let options; + function visit(node) { + if (!node || typeof node !== 'object') return; + if (node.type === 'ImportDeclaration') { + assert.notStrictEqual(node.source.value, 'disable-devtool', `${file} bypasses shared control`); + } + if (node.type === 'CallExpression' && node.callee.name === 'initDisableDevtool') options = node.arguments[0]; + for (const value of Object.values(node)) { + if (Array.isArray(value)) value.forEach(visit); + else if (value && typeof value === 'object') visit(value); + } + } + visit(ast); + assert(options, `${file} must initialize through shared control`); + // Also verify the entire JSX file remains compatible with project Babel presets. + compile(file); + return source.slice(options.start, options.end); +} + +const storage = new Map(); +test('home exposes stopDev and persists it for navigation', () => { + const home = page(storage); + vm.runInContext(homeCode, home.context); + home.window.stopDev(); + assert.strictEqual(storage.get('stopDev'), '1'); + assert.strictEqual(home.detector.isSuspend, true); +}); + +for (const mode of ['stage-unity', 'stage-python-unity', 'stage-cpp-unity']) { + const optionSource = stageOptions(`src/components/${mode}/stage-unity.jsx`); + test(`${mode}: home -> editor with F12 open does not start detection or clear code`, () => { + const editor = page(storage, {immediate: true}); + const options = vm.runInContext(`(${optionSource})`, editor.context); + editor.api.initDisableDevtool(options); + assert.strictEqual(editor.starts.length, 0); + assert.strictEqual(editor.detector.isSuspend, true); + assert.strictEqual(editor.window.location.href, '/editor.html'); + assert.deepStrictEqual(editor.effects, []); + // A refresh or another mode initialization in this tab must stay stopped. + editor.api.initDisableDevtool(options); + assert.strictEqual(editor.starts.length, 0); + }); + test(`${mode}: stopDev blocks an already queued destructive callback`, () => { + const editor = page(); + const options = vm.runInContext(`(${optionSource})`, editor.context); + editor.api.initDisableDevtool(options); + assert.strictEqual(editor.starts.length, 1); + const callback = editor.starts[0]; + editor.window.stopDev(); + assert.strictEqual(callback.ignore(), true); + callback.ondevtoolopen('queued'); + assert.strictEqual(editor.window.location.href, '/editor.html'); + assert.deepStrictEqual(editor.effects, []); + }); +} + +test('normal session retains default detection and redirect', () => { + const normal = page(); + normal.api.initDisableDevtool(); + assert.strictEqual(normal.starts.length, 1); + assert.strictEqual(normal.starts[0].ignore(), false); + normal.starts[0].ondevtoolopen(); + assert.strictEqual(normal.window.location.href, '/index.html'); +}); +test('custom options, ignore and callback arguments remain intact', () => { + const normal = page(); + const seen = []; + const next = () => {}; + normal.api.initDisableDevtool({interval: 123, ignore: () => true, ondevtoolopen: (...args) => seen.push(args)}); + assert.strictEqual(normal.starts[0].interval, 123); + assert.strictEqual(normal.starts[0].ignore(), true); + normal.starts[0].ondevtoolopen(7, next); + assert.deepStrictEqual(seen, [[7, next]]); +}); +test('disabled configuration does not initialize; stopDev remains available', () => { + const development = page(new Map(), {enabled: false}); + development.api.initDisableDevtool(); + assert.strictEqual(development.starts.length, 0); + assert.strictEqual(typeof development.window.stopDev, 'function'); +}); +test('blocked storage still permits immediate stop on current page', () => { + const broken = page(new Map(), {brokenStorage: true}); + broken.api.initDisableDevtool(); + broken.window.stopDev(); + assert.strictEqual(broken.detector.isSuspend, true); + assert.strictEqual(broken.starts[0].ignore(), true); + broken.starts[0].ondevtoolopen(); + broken.api.initDisableDevtool(); + assert.strictEqual(broken.starts.length, 1); + assert.strictEqual(broken.window.location.href, '/editor.html'); +}); +for (const file of ['src/playground/competition.js', 'src/playground/competition_sum.js']) { + const optionsSource = stageOptions(file); + test(`${file}: same stopDev state is honored`, () => { + const competition = page(storage, {immediate: true}); + competition.api.initDisableDevtool(vm.runInContext(`(${optionsSource})`, competition.context)); + assert.strictEqual(competition.starts.length, 0); + }); +} +console.log(`${checks} devtool regression checks passed`); diff --git a/scratch-gui/src/components/stage-cpp-unity/stage-unity.jsx b/scratch-gui/src/components/stage-cpp-unity/stage-unity.jsx index a10c9173..2d5f1d36 100644 --- a/scratch-gui/src/components/stage-cpp-unity/stage-unity.jsx +++ b/scratch-gui/src/components/stage-cpp-unity/stage-unity.jsx @@ -11,7 +11,6 @@ import RankBadgeGenerator from '../../playground/rank-badge-generator.js'; // const friendToggle = new FriendToggle(); window.RankBadgeGenerator = RankBadgeGenerator; window.CONFIG = CONFIG; -import disableDevtool from 'disable-devtool'; import { initDisableDevtool } from '../../playground/devtool-control.js'; import { getUnityPlayerConfig, loadUnityLoaderScript } from '../../lib/unity-build-loader.js'; import { debug } from 'scratch-vm/src/util/log.js'; @@ -561,7 +560,7 @@ const UnityComponent = function (props) { useEffect(() => { if (CONFIG.disabledev) { - disableDevtool({ + initDisableDevtool({ ondevtoolopen: () => { // 临时禁用 onbeforeunload 事件监听,以防止弹出确认离开弹窗 @@ -1906,4 +1905,4 @@ UnityComponent.propTypes = { vm: PropTypes.instanceOf(VM).isRequired }; -export default UnityComponent; \ No newline at end of file +export default UnityComponent; diff --git a/scratch-gui/src/components/stage-cpp/cpp-parser.js b/scratch-gui/src/components/stage-cpp/cpp-parser.js index f33304d2..c4b95f5c 100644 --- a/scratch-gui/src/components/stage-cpp/cpp-parser.js +++ b/scratch-gui/src/components/stage-cpp/cpp-parser.js @@ -510,10 +510,49 @@ export class CppSyntaxValidator { } } +// 声明写入当前作用域,赋值更新最近的已有变量;函数不继承调用者的局部变量。 +class CppVariableScope extends Map { + constructor(parent = null) { + super(); + this.parent = parent; + } + + declare(name, value) { + return super.set(name, value); + } + + has(name) { + return super.has(name) || Boolean(this.parent && this.parent.has(name)); + } + + get(name) { + return super.has(name) ? super.get(name) : this.parent && this.parent.get(name); + } + + set(name, value) { + if (!super.has(name) && this.parent && this.parent.has(name)) { + this.parent.set(name, value); + return this; + } + return super.set(name, value); + } + + *entries() { + const visible = new Map(this.parent ? this.parent.entries() : []); + for (const [name, value] of super.entries()) visible.set(name, value); + yield* visible.entries(); + } + + [Symbol.iterator]() { + return this.entries(); + } +} + export class CppCodeExecutor { constructor(code, setHighlightedLine) { this.code = code; - this.variables = new Map(); + this.globalVariables = new CppVariableScope(); + this.variables = this.globalVariables; this.functions = new Map(); // 存储自定义函数 this.currentLine = 0; this.lines = []; @@ -527,7 +566,7 @@ export class CppCodeExecutor { try { this.isRunning = true; this.parseCode(); - await this.executeMain(); + await this.withVariableScope(() => this.executeMain(), this.globalVariables); } catch (error) { console.error('C++执行错误:', error); throw error; @@ -544,6 +583,16 @@ export class CppCodeExecutor { return !this.isRunning || window.isScriptRunOver; } + async withVariableScope(action, parent = this.variables) { + const previous = this.variables; + this.variables = new CppVariableScope(parent); + try { + return await action(); + } finally { + this.variables = previous; + } + } + parseCode() { debugLog('🔍 开始解析C++代码,支持自定义函数'); @@ -1300,10 +1349,10 @@ export class CppCodeExecutor { await this.executeSimpleStatement(statement.content); break; case 'for': - await this.executeForLoop(statement); + await this.withVariableScope(() => this.executeForLoop(statement)); break; case 'if': - await this.executeIfStatement(statement); + await this.withVariableScope(() => this.executeIfStatement(statement)); break; case 'while': await this.executeWhileLoop(statement); @@ -1323,7 +1372,21 @@ export class CppCodeExecutor { async executeSimpleStatement(content) { debugLog(`🔍 执行简单语句: ${content}`); + + // 先识别输出语句,避免把字符串里的 =、++、游戏函数名当作代码执行。 + if (/^\s*(?:std\s*::\s*)?cout\s*< 第${bodyStatement.exactLineNumber}行`); - } else { - adjustedStatement = this.adjustStatementLineNumber.call(this, bodyStatement); - } - await this.executeStatement(adjustedStatement); + try { + await this.withVariableScope(async () => { + for (const bodyStatement of forStatement.body) { + if (this.shouldStop()) break; + + // 检查是否是break或continue语句 + if ((bodyStatement.content && bodyStatement.content.trim() === 'break;') || + (bodyStatement.type === 'control' && bodyStatement.subtype === 'break')) { + debugLog('🔄 遇到break语句,退出for循环'); + shouldBreak = true; + break; + } + + if ((bodyStatement.content && bodyStatement.content.trim() === 'continue;') || + (bodyStatement.type === 'control' && bodyStatement.subtype === 'continue')) { + debugLog('🔄 遇到continue语句,跳过本次循环剩余部分'); + shouldContinue = true; + break; + } + + // 使用预先计算好的精确行号,如果没有则使用调整后的行号 + let adjustedStatement; + if (bodyStatement.exactLineNumber) { + adjustedStatement = { + ...bodyStatement, + lineNumber: bodyStatement.exactLineNumber + }; + debugLog(`🎯 使用精确行号: "${bodyStatement.content}" -> 第${bodyStatement.exactLineNumber}行`); + } else { + adjustedStatement = this.adjustStatementLineNumber.call(this, bodyStatement); + } + await this.executeStatement(adjustedStatement); + } + + }); + } finally { + this.currentExecutionContext = oldContext; } - this.currentExecutionContext = oldContext; - - if (this.shouldStop()) break; + if (this.shouldStop() || shouldBreak) break; // 如果遇到continue,跳过执行增量,直接进入下次循环 if (shouldContinue) { @@ -1730,43 +1761,48 @@ export class CppCodeExecutor { let shouldContinue = false; - for (const bodyStatement of whileStatement.body) { - if (this.shouldStop()) break; - - // 检查是否是break或continue语句 - if ((bodyStatement.content && bodyStatement.content.trim() === 'break;') || - (bodyStatement.type === 'control' && bodyStatement.subtype === 'break')) { - debugLog('🔄 遇到break语句,退出while循环'); - shouldBreak = true; - break; - } - - if ((bodyStatement.content && bodyStatement.content.trim() === 'continue;') || - (bodyStatement.type === 'control' && bodyStatement.subtype === 'continue')) { - debugLog('🔄 遇到continue语句,跳过本次循环剩余部分'); - shouldContinue = true; - break; - } - - // 使用预先计算好的精确行号,如果没有则使用调整后的行号 - let adjustedStatement; - if (bodyStatement.exactLineNumber) { - adjustedStatement = { - ...bodyStatement, - lineNumber: bodyStatement.exactLineNumber - }; - debugLog(`🎯 使用精确行号: "${bodyStatement.content}" -> 第${bodyStatement.exactLineNumber}行`); - } else { - adjustedStatement = this.adjustStatementLineNumber.call(this, bodyStatement); - } - await this.executeStatement(adjustedStatement); - - // 在每个语句执行后重新检查循环条件(特别重要!) - debugLog(`🔍 语句执行后重新检查条件: ${whileStatement.condition} = ${this.evaluateCondition(whileStatement.condition)}`); + try { + await this.withVariableScope(async () => { + for (const bodyStatement of whileStatement.body) { + if (this.shouldStop()) break; + + // 检查是否是break或continue语句 + if ((bodyStatement.content && bodyStatement.content.trim() === 'break;') || + (bodyStatement.type === 'control' && bodyStatement.subtype === 'break')) { + debugLog('🔄 遇到break语句,退出while循环'); + shouldBreak = true; + break; + } + + if ((bodyStatement.content && bodyStatement.content.trim() === 'continue;') || + (bodyStatement.type === 'control' && bodyStatement.subtype === 'continue')) { + debugLog('🔄 遇到continue语句,跳过本次循环剩余部分'); + shouldContinue = true; + break; + } + + // 使用预先计算好的精确行号,如果没有则使用调整后的行号 + let adjustedStatement; + if (bodyStatement.exactLineNumber) { + adjustedStatement = { + ...bodyStatement, + lineNumber: bodyStatement.exactLineNumber + }; + debugLog(`🎯 使用精确行号: "${bodyStatement.content}" -> 第${bodyStatement.exactLineNumber}行`); + } else { + adjustedStatement = this.adjustStatementLineNumber.call(this, bodyStatement); + } + await this.executeStatement(adjustedStatement); + + // 在每个语句执行后重新检查循环条件(特别重要!) + debugLog(`🔍 语句执行后重新检查条件: ${whileStatement.condition} = ${this.evaluateCondition(whileStatement.condition)}`); + } + + }); + } finally { + this.currentExecutionContext = oldContext; } - this.currentExecutionContext = oldContext; - if (shouldContinue) { continue; // 跳过本次循环的剩余部分,重新开始 } @@ -1804,6 +1840,11 @@ export class CppCodeExecutor { throw new Error(`函数调用栈溢出: ${functionName} (可能存在无限递归)`); } + // 所有实参先在调用者作用域求值,避免前一个形参覆盖后一个实参。 + const argumentValues = callArguments.map(arg => this.evaluateExpression(arg)); + const callerVariables = this.variables; + this.variables = new CppVariableScope(this.globalVariables); + // 将函数调用信息压入调用栈 this.callStack.push({ functionName: functionName, @@ -1812,21 +1853,14 @@ export class CppCodeExecutor { }); try { - // 最小改动:防递归数据污染,只备份会被覆盖的同名参数变量 - const savedParams = new Map(); - for (let i = 0; i < functionInfo.parameters.length; i++) { - const pName = functionInfo.parameters[i].name; - if (this.variables.has(pName)) savedParams.set(pName, this.variables.get(pName)); - } - // 设置函数参数变量 for (let i = 0; i < functionInfo.parameters.length; i++) { const param = functionInfo.parameters[i]; - const argValue = this.evaluateExpression(callArguments[i]); + const argValue = argumentValues[i]; // 类型检查和转换 const convertedValue = this.convertArgumentToType(argValue, param.type); - this.variables.set(param.name, convertedValue); + this.variables.declare(param.name, convertedValue); console.log(`📋 设置参数: ${param.name} (${param.type}) = ${convertedValue}`); } @@ -1859,19 +1893,10 @@ export class CppCodeExecutor { if (e.message !== 'RETURN_INTERRUPT') throw e; } - // 最小改动:仅恢复递归调用前的参数状态,保留全局变量的修改 - for (let i = 0; i < functionInfo.parameters.length; i++) { - const pName = functionInfo.parameters[i].name; - if (savedParams.has(pName)) { - this.variables.set(pName, savedParams.get(pName)); - } else { - this.variables.delete(pName); - } - } - console.log(`✅ 函数${functionName}执行完成`); } finally { + this.variables = callerVariables; // 从调用栈中弹出当前函数 const callInfo = this.callStack.pop(); const executionTime = Date.now() - callInfo.startTime; @@ -2201,7 +2226,7 @@ export class CppCodeExecutor { // 处理复杂的cout语句 executeCoutStatement(content) { // 移除cout和分号,获取输出内容 - let coutContent = content.replace(/^\s*cout\s*/, '').replace(/;\s*$/, ''); + const coutContent = content.replace(/^\s*(?:std\s*::\s*)?cout\s*/, '').replace(/;\s*$/, ''); // 按 << 分割 const parts = this.splitCoutParts(coutContent); @@ -2211,14 +2236,14 @@ export class CppCodeExecutor { for (const part of parts) { const trimmedPart = part.trim(); - if (trimmedPart === 'endl') { + if (/^(?:std\s*::\s*)?endl$/.test(trimmedPart)) { output += '\n'; } else if (trimmedPart.startsWith('"') && trimmedPart.endsWith('"')) { // 字符串字面量 - output += trimmedPart.slice(1, -1); + output += this.decodeStringLiteral(trimmedPart); } else if (trimmedPart.startsWith("'") && trimmedPart.endsWith("'")) { // 字符字面量 - output += trimmedPart.slice(1, -1); + output += this.decodeStringLiteral(trimmedPart); } else if (trimmedPart.startsWith('(') && trimmedPart.endsWith(')')) { // 括号表达式 const expr = trimmedPart.slice(1, -1); @@ -2233,7 +2258,13 @@ export class CppCodeExecutor { window.cppGameAPI.cmdPrint(output); } - // 智能分割cout的各个部分 + // 解码输出中的常用 C++ 转义字符。 + decodeStringLiteral(literal) { + const escapes = {n: '\n', r: '\r', t: '\t', '0': '\0', '\\': '\\', '"': '"', "'": "'"}; + return literal.slice(1, -1).replace(/\\([nrt0\\"'])/g, (match, char) => escapes[char]); + } + + // 按字符串和括号边界分割输出流。 splitCoutParts(content) { const parts = []; let current = ''; @@ -2244,6 +2275,12 @@ export class CppCodeExecutor { for (let i = 0; i < content.length; i++) { const char = content[i]; const nextChar = content[i + 1]; + + if (inString && char === '\\' && nextChar !== undefined) { + current += char + nextChar; + i++; + continue; + } if (!inString && inParens === 0 && char === '<' && nextChar === '<') { // 遇到 << 分隔符 @@ -2284,6 +2321,11 @@ export class CppCodeExecutor { expr = expr.trim().replace(/;$/, ''); debugLog(`🔍 计算表达式: ${expr}`); + // 字符串中的 ++、-- 和函数名都是文本,不参与表达式运算。 + if (/^("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')$/.test(expr)) { + return this.decodeStringLiteral(expr); + } + // 布尔常量 if (expr === 'true') { debugLog(`🔍 布尔常量: true = 1`); @@ -2338,7 +2380,7 @@ export class CppCodeExecutor { const newValue = currentValue + 1; this.variables.set(varName, newValue); console.log(`🔄 自增操作: ${expr} (${currentValue} -> ${newValue})`); - return newValue; + return expr.startsWith('++') ? newValue : currentValue; } if (expr.includes('--')) { @@ -2356,7 +2398,7 @@ export class CppCodeExecutor { const newValue = currentValue - 1; this.variables.set(varName, newValue); console.log(`🔄 自减操作: ${expr} (${currentValue} -> ${newValue})`); - return newValue; + return expr.startsWith('--') ? newValue : currentValue; } // 游戏API函数调用 @@ -2386,12 +2428,6 @@ export class CppCodeExecutor { return position; } - // 整个表达式是字符串字面量时才当字符串;含引号的 API 调用走后面的算术解析 - if ((expr.startsWith('"') && expr.endsWith('"')) || - (expr.startsWith("'") && expr.endsWith("'"))) { - return expr.slice(1, -1); - } - return this.parseComplexExpression(expr); } @@ -3720,4 +3756,4 @@ export const testCompleteIfElseIfElse = async () => { } return result; -}; \ No newline at end of file +}; diff --git a/scratch-gui/src/components/stage-cpp/stage-cpp-clang.jsx b/scratch-gui/src/components/stage-cpp/stage-cpp-clang.jsx index 9f87b906..835398d8 100644 --- a/scratch-gui/src/components/stage-cpp/stage-cpp-clang.jsx +++ b/scratch-gui/src/components/stage-cpp/stage-cpp-clang.jsx @@ -645,17 +645,21 @@ const StageWrapperCppClang = () => { textStr = String(text); } - window.cmd_print_text += textStr + '\n'; + // cout 自己决定何时换行,连续输出应保持在同一行。 + window.cmd_print_text += textStr; const lines = window.cmd_print_text.split('\n'); if (lines.length > 20) { window.cmd_print_text = lines.slice(lines.length - 20).join('\n'); } + setCompilationOutput(prev => prev + textStr); if (window.unityInstance) { - window.unityInstance.SendMessage("UIMain", "SetText", window.cmd_print_text); + try { + window.unityInstance.SendMessage("UIMain", "SetText", window.cmd_print_text); + } catch (error) { + console.warn('游戏画面输出失败,文本仍可在运行输出中查看', error); + } } - - setCompilationOutput(prev => prev + textStr + "\n"); }, player_data: (direction) => { @@ -795,6 +799,7 @@ const StageWrapperCppClang = () => { setCompilationOutput(prev => prev + "语法检查通过!\n"); // 重置停止标志 + window.cmd_print_text = ''; window.isScriptRunOver = false; setIsRunning(true); setOverlayActive(true); @@ -969,7 +974,7 @@ const StageWrapperCppClang = () => {
-

编译信息

+

运行输出

@@ -1353,4 +1365,4 @@ const StageWrapperCppClang = () => { ); }; -export default StageWrapperCppClang; \ No newline at end of file +export default StageWrapperCppClang; diff --git a/scratch-gui/src/components/stage-python-unity/stage-unity.jsx b/scratch-gui/src/components/stage-python-unity/stage-unity.jsx index eba69d70..d12b9b6b 100644 --- a/scratch-gui/src/components/stage-python-unity/stage-unity.jsx +++ b/scratch-gui/src/components/stage-python-unity/stage-unity.jsx @@ -11,7 +11,6 @@ import RankBadgeGenerator from '../../playground/rank-badge-generator.js'; // const friendToggle = new FriendToggle(); window.RankBadgeGenerator = RankBadgeGenerator; window.CONFIG = CONFIG; -import disableDevtool from 'disable-devtool'; import { initDisableDevtool } from '../../playground/devtool-control.js'; import { getUnityPlayerConfig, loadUnityLoaderScript } from '../../lib/unity-build-loader.js'; import { debug } from 'scratch-vm/src/util/log.js'; @@ -561,7 +560,7 @@ const UnityComponent = function (props) { useEffect(() => { if (CONFIG.disabledev) { - disableDevtool({ + initDisableDevtool({ ondevtoolopen: () => { // 临时禁用 onbeforeunload 事件监听,以防止弹出确认离开弹窗 @@ -1892,4 +1891,4 @@ UnityComponent.propTypes = { vm: PropTypes.instanceOf(VM).isRequired }; -export default UnityComponent; \ No newline at end of file +export default UnityComponent; diff --git a/scratch-gui/src/components/stage-unity/stage-unity.jsx b/scratch-gui/src/components/stage-unity/stage-unity.jsx index 3059780f..5b6d63fd 100644 --- a/scratch-gui/src/components/stage-unity/stage-unity.jsx +++ b/scratch-gui/src/components/stage-unity/stage-unity.jsx @@ -11,7 +11,6 @@ import RankBadgeGenerator from '../../playground/rank-badge-generator.js'; // const friendToggle = new FriendToggle(); window.RankBadgeGenerator = RankBadgeGenerator; window.CONFIG = CONFIG; -import disableDevtool from 'disable-devtool'; import { initDisableDevtool } from '../../playground/devtool-control.js'; import { getUnityPlayerConfig, loadUnityLoaderScript } from '../../lib/unity-build-loader.js'; import { debug } from 'scratch-vm/src/util/log.js'; @@ -755,7 +754,7 @@ const UnityComponent = function (props) { useEffect(() => { if (CONFIG.disabledev) { - disableDevtool({ + initDisableDevtool({ ondevtoolopen: () => { // 临时禁用 onbeforeunload 事件监听,以防止弹出确认离开弹窗 @@ -2162,4 +2161,4 @@ UnityComponent.propTypes = { vm: PropTypes.instanceOf(VM).isRequired }; -export default UnityComponent; \ No newline at end of file +export default UnityComponent; diff --git a/scratch-gui/src/playground/aboutus.ejs b/scratch-gui/src/playground/aboutus.ejs index a5012c8d..cac2ff1b 100644 --- a/scratch-gui/src/playground/aboutus.ejs +++ b/scratch-gui/src/playground/aboutus.ejs @@ -1325,7 +1325,7 @@

灵丌编程(001CODE)城市合伙人招募中

diff --git a/scratch-gui/src/playground/competition.js b/scratch-gui/src/playground/competition.js index 42cdab4b..6457e821 100644 --- a/scratch-gui/src/playground/competition.js +++ b/scratch-gui/src/playground/competition.js @@ -1,6 +1,6 @@ // 导入配置信息 import CONFIG from './config.js'; -import disableDevtool from 'disable-devtool'; +import { initDisableDevtool } from './devtool-control.js'; // 获取 URL 参数的函数 @@ -166,7 +166,7 @@ function showPlatformGuide() { document.addEventListener('DOMContentLoaded', function () { if (CONFIG.disabledev) { - disableDevtool({ + initDisableDevtool({ ondevtoolopen: () => { // window.location.href = '/index.html'; // window.location.href = localStorage.getItem('returnUrl'); @@ -3449,4 +3449,4 @@ function getLanguageName(t) { } // 导出函数,以便其他模块可以使用 -export { getUrlParameter, fetchCompetitionInfo }; \ No newline at end of file +export { getUrlParameter, fetchCompetitionInfo }; diff --git a/scratch-gui/src/playground/competition_sum.js b/scratch-gui/src/playground/competition_sum.js index 55920f69..5bb34b80 100644 --- a/scratch-gui/src/playground/competition_sum.js +++ b/scratch-gui/src/playground/competition_sum.js @@ -1,11 +1,11 @@ import CONFIG from './config.js'; -import disableDevtool from 'disable-devtool'; +import { initDisableDevtool } from './devtool-control.js'; let gamelvdata = {}; document.addEventListener('DOMContentLoaded', function () { if (CONFIG.disabledev) { - disableDevtool({ + initDisableDevtool({ ondevtoolopen: () => { }, interval: 500, diff --git a/scratch-gui/src/playground/config.js b/scratch-gui/src/playground/config.js index 0e5de0c4..4607f16b 100644 --- a/scratch-gui/src/playground/config.js +++ b/scratch-gui/src/playground/config.js @@ -183,7 +183,7 @@ const CONFIG = { - disabledev: false, + disabledev: true, usecdn: false, lvlock: false, lvlock_normal_learn: !(typeof localStorage !== 'undefined' && diff --git a/scratch-gui/src/playground/devtool-control.js b/scratch-gui/src/playground/devtool-control.js index a00170ea..347db3fd 100644 --- a/scratch-gui/src/playground/devtool-control.js +++ b/scratch-gui/src/playground/devtool-control.js @@ -2,8 +2,10 @@ import disableDevtool from 'disable-devtool'; import CONFIG from './config.js'; const STOP_DEV_KEY = 'stopDev'; +let stoppedForPage = false; function isStopped() { + if (stoppedForPage) return true; try { return sessionStorage.getItem(STOP_DEV_KEY) === '1'; } catch (e) { @@ -16,6 +18,8 @@ function isStopped() { * 可在控制台直接执行:stopDev() */ export function stopDev() { + // 即使浏览器拒绝写入 sessionStorage,当前页面仍应立即停止检测。 + stoppedForPage = true; try { sessionStorage.setItem(STOP_DEV_KEY, '1'); } catch (e) { @@ -29,6 +33,8 @@ export function initDisableDevtool(options = {}) { if (isStopped()) { disableDevtool.isSuspend = true; + // F12 已打开时,库可能在初始化期间立即触发检测,不能继续初始化。 + return; } if (!CONFIG.disabledev) { @@ -36,14 +42,19 @@ export function initDisableDevtool(options = {}) { } const userIgnore = options.ignore; + const onDevtoolOpen = options.ondevtoolopen || (() => { + window.location.href = localStorage.getItem('returnUrl'); + }); disableDevtool({ - ondevtoolopen: () => { - window.location.href = localStorage.getItem('returnUrl'); - }, interval: 500, clearLog: true, disableMenu: true, ...options, + ondevtoolopen: (...args) => { + // stopDev() 之后,已排队的检测回调也不能清空代码或跳转。 + if (isStopped()) return; + return onDevtoolOpen(...args); + }, ignore: () => { if (isStopped()) { return true;