From 3bdde094f8d5a8fad88780e934a7667e02c363ca Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Thu, 11 Jun 2026 11:05:31 +0300 Subject: [PATCH] waitable blockly scripts. new waitForSeconds block --- eslint.config.js | 9 +++ src/blockly/actions/consoleLog.ts | 2 +- src/blockly/actions/index.ts | 1 + src/blockly/actions/waitSeconds.ts | 22 +++++++ src/blockly/generateCode.ts | 12 ++-- .../scriptEditor/ScriptEditorView.tsx | 2 +- src/model/GameScheduler.ts | 63 +++++++++++++++++++ src/model/gameFactory.ts | 29 +++++++-- src/state/gameState.ts | 6 +- src/types/runtime/gameBus.ts | 6 +- 10 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 src/blockly/actions/waitSeconds.ts create mode 100644 src/model/GameScheduler.ts diff --git a/eslint.config.js b/eslint.config.js index ef614d2..986a5c3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,5 +18,14 @@ export default defineConfig([ languageOptions: { globals: globals.browser, }, + rules: { + "padding-line-between-statements": [ + "warn", + { blankLine: 'always', prev: '*', next: 'block' }, + { blankLine: 'always', prev: 'block', next: '*' }, + { blankLine: 'always', prev: '*', next: 'block-like' }, + { blankLine: 'always', prev: 'block-like', next: '*' }, + ] + }, }, ]) diff --git a/src/blockly/actions/consoleLog.ts b/src/blockly/actions/consoleLog.ts index 806a302..3c283be 100644 --- a/src/blockly/actions/consoleLog.ts +++ b/src/blockly/actions/consoleLog.ts @@ -5,7 +5,7 @@ Blockly.Blocks['console_log_action'] = { init(this: Blockly.Block) { this.appendValueInput('VALUE') .setAlign(Blockly.inputs.Align.RIGHT) - .setCheck('ObjectType') + // .setCheck('String') .appendField('print'); this.setInputsInline(false) this.setPreviousStatement(true, null); diff --git a/src/blockly/actions/index.ts b/src/blockly/actions/index.ts index 2d359d1..014a712 100644 --- a/src/blockly/actions/index.ts +++ b/src/blockly/actions/index.ts @@ -1,2 +1,3 @@ export * from './consoleLog'; export * from './physics'; +export * from './waitSeconds'; diff --git a/src/blockly/actions/waitSeconds.ts b/src/blockly/actions/waitSeconds.ts new file mode 100644 index 0000000..904464b --- /dev/null +++ b/src/blockly/actions/waitSeconds.ts @@ -0,0 +1,22 @@ +import * as Blockly from "blockly"; +import { javascriptGenerator } from "blockly/javascript"; + +Blockly.Blocks['wait_seconds_action'] = { + init(this: Blockly.Block) { + this.appendDummyInput() + .appendField('Wait') + .appendField(new Blockly.FieldNumber(1, 0, 3600, 0.1), 'SECONDS') + .appendField('seconds'); + this.setPreviousStatement(true, null); + this.setNextStatement(true, null); + this.setTooltip('Pause execution for N seconds'); + this.setColour(120); + } +}; + +javascriptGenerator.forBlock['wait_seconds_action'] = function (block) { + const seconds = block.getFieldValue('SECONDS'); + return `yield wait(${seconds});\n`; +}; + +export { }; diff --git a/src/blockly/generateCode.ts b/src/blockly/generateCode.ts index 9db0d34..253b203 100644 --- a/src/blockly/generateCode.ts +++ b/src/blockly/generateCode.ts @@ -12,7 +12,7 @@ export function generateCode(workspace: Blockly.WorkspaceSvg): string { body += javascriptGenerator.blockToCode(next, true); next = next.getNextBlock(); } - body = javascriptGenerator.prefixLines(body, javascriptGenerator.INDENT); + body = javascriptGenerator.prefixLines(body, javascriptGenerator.INDENT.repeat(2)); // const args = Array.from(block.getFields()) // .filter(field => field.name !== undefined) @@ -22,13 +22,11 @@ export function generateCode(workspace: Blockly.WorkspaceSvg): string { //TODO use args result += `onGameEvent( -'${block.type.replace(/_event$/, '')}', -function(context) { - // console.dir({context, api, object, objectType}); + '${block.type.replace(/_event$/, '')}', + function*(context) { if (context.object && (context.object.id !== object.id)) - return; - ${body} -} + return; +${body} }, ); `; } diff --git a/src/components/scriptEditor/ScriptEditorView.tsx b/src/components/scriptEditor/ScriptEditorView.tsx index d280f7a..42896fd 100644 --- a/src/components/scriptEditor/ScriptEditorView.tsx +++ b/src/components/scriptEditor/ScriptEditorView.tsx @@ -45,8 +45,8 @@ const TOOLBOX: Blockly.utils.toolbox.ToolboxDefinition = { colour: '20', contents: [ { kind: 'block', type: 'console_log_action' }, - { kind: 'block', type: 'text_print' }, { kind: 'block', type: 'physics_apply_impulse_action' }, + { kind: 'block', type: 'wait_seconds_action' }, ], }, { diff --git a/src/model/GameScheduler.ts b/src/model/GameScheduler.ts new file mode 100644 index 0000000..637237b --- /dev/null +++ b/src/model/GameScheduler.ts @@ -0,0 +1,63 @@ +export type YieldCommand = + | { type: 'waitSeconds'; seconds: number } + | { type: 'waitFrames'; frames: number } + | { type: 'waitUntil'; condition: () => boolean }; + +type Coroutine = + & { gen: Generator } + & (| { wakeType: 'time'; remaining: number } + | { wakeType: 'frames'; remaining: number } + | { wakeType: 'condition'; test: () => boolean }); + +export class GameScheduler { + private coroutines: Coroutine[] = []; + + tick(deltaTime: number): void { + const next: Coroutine[] = []; + for (const co of this.coroutines) { + if (this.isReady(co)) this.step(co, next); + else next.push(this.countdown(co, deltaTime)); + } + this.coroutines = next; + } + + startCoroutine(gen: Generator): void { + const result = gen.next(); + if (!result.done) + this.coroutines.push(this.make(gen, result.value)); + } + + reset(): void { this.coroutines = []; } + + private isReady(co: Coroutine): boolean { + switch (co.wakeType) { + case 'time': return co.remaining <= 0; + case 'frames': return co.remaining <= 0; + case 'condition': return co.test(); + } + } + + private countdown(co: Coroutine, deltaTime: number): Coroutine { + switch (co.wakeType) { + case 'time': return { ...co, remaining: co.remaining - deltaTime }; + case 'frames': return { ...co, remaining: co.remaining - 1 }; + default: return co; + } + } + + private step(co: Coroutine, list: Coroutine[]): void { + const result = co.gen.next(); + if (!result.done) list.push(this.make(co.gen, result.value)); + } + + private make(gen: Generator, cmd: YieldCommand): Coroutine { + switch (cmd.type) { + case 'waitSeconds': + return { gen, wakeType: 'time', remaining: cmd.seconds }; + case 'waitFrames': + return { gen, wakeType: 'frames', remaining: cmd.frames }; + case 'waitUntil': + return { gen, wakeType: 'condition', test: cmd.condition }; + } + } +} diff --git a/src/model/gameFactory.ts b/src/model/gameFactory.ts index 8fe9257..2653fc3 100644 --- a/src/model/gameFactory.ts +++ b/src/model/gameFactory.ts @@ -1,8 +1,9 @@ import type { Game, RuntimeGameScene, RuntimeScene, World } from "../types"; -import type { GameEventHandler, GameEventContext, InternalGameEventBus } from "../types/runtime/gameBus"; +import type { GameEventContext, GameEventHandler, InternalGameEventBus } from "../types/runtime/gameBus"; import { clone } from "../utils"; import { populateRuntimeScene } from "../utils/runtime"; import { ObjectApi } from "../utils/runtime/objectApi"; +import type { GameScheduler, YieldCommand } from "./GameScheduler"; export class GameEventBus { private handlers = new Map(); @@ -66,25 +67,41 @@ export class GameFactory { world: World, scene: RuntimeGameScene, eventBus: GameEventBus, - + scheduler: GameScheduler, ): void { + const handlerWrappers = new WeakMap void>(); + + const wait = (seconds: number): YieldCommand => ({ type: 'waitSeconds', seconds }); + const waitFrames = (frames: number): YieldCommand => ({ type: 'waitFrames', frames }); + const waitUntil = (condition: () => boolean): YieldCommand => ({ type: 'waitUntil', condition }); + const internalBus: InternalGameEventBus = { - onGameEvent: eventBus.on.bind(eventBus), - offGameEvent: eventBus.off.bind(eventBus), + onGameEvent(event, genFn) { + const wrapper: GameEventHandler = (ctx) => scheduler.startCoroutine(genFn(ctx) as Generator); + handlerWrappers.set(genFn, wrapper); + eventBus.on(event, wrapper); + }, + offGameEvent(event, genFn) { + const wrapper = handlerWrappers.get(genFn); + if (wrapper) { + eventBus.off(event, wrapper); + handlerWrappers.delete(genFn); + } + }, }; try { Object.values(world.objectTypes) .filter((ot) => ot.javascript) .forEach((ot) => { - const gameScript = eval(`(function gameScript({onGameEvent, offGameEvent }, {api, object, objectType}) {${ot.javascript}})`); + const gameScript = eval(`(function gameScript({onGameEvent, offGameEvent, wait, waitFrames, waitUntil}, {api, object, objectType}) {${ot.javascript}})`); Object.values(scene.objects) .filter((o) => o.typeId == ot.id) .forEach((o) => { const api = new ObjectApi(o, ot, world, scene); - gameScript(internalBus, { api, object: o, objectType: ot }); + gameScript({ ...internalBus, wait, waitFrames, waitUntil }, { api, object: o, objectType: ot }); }) }); } diff --git a/src/state/gameState.ts b/src/state/gameState.ts index edd6791..b558dfa 100644 --- a/src/state/gameState.ts +++ b/src/state/gameState.ts @@ -3,6 +3,7 @@ import type { WorldState } from "./worldState"; import type { Game, Pos3, RuntimeGameScene } from "../types"; import type { CameraProps } from "@react-three/fiber"; import { GameEventBus, GameFactory } from "../model/gameFactory"; +import { GameScheduler } from "../model/GameScheduler"; import type { GameEventContext } from "../types/runtime/gameBus"; export class GameState { @@ -14,6 +15,7 @@ export class GameState { public scene: RuntimeGameScene; private eventBus: GameEventBus; + private scheduler: GameScheduler; private startAutoSave() { return reaction( @@ -46,11 +48,12 @@ export class GameState { const eventBus = new GameEventBus(); this.eventBus = eventBus; + this.scheduler = new GameScheduler(); this._stopAutoSave = this.startAutoSave(); makeAutoObservable(this); - GameFactory.evalGameScripts(rawWorld, this.scene, eventBus); + GameFactory.evalGameScripts(rawWorld, this.scene, eventBus, this.scheduler); } public emitEvent(event: string, context: Omit = {}) { @@ -126,6 +129,7 @@ export class GameState { if (this.isPaused) return; + this.scheduler.tick(deltaTime); this.time += deltaTime; if (this.isFirstTick) { diff --git a/src/types/runtime/gameBus.ts b/src/types/runtime/gameBus.ts index fcad4df..9845454 100644 --- a/src/types/runtime/gameBus.ts +++ b/src/types/runtime/gameBus.ts @@ -8,7 +8,9 @@ export type GameEventContext = { export type GameEventHandler = (context: GameEventContext) => void; +export type GeneratorEventHandler = (context: GameEventContext) => Generator; + export type InternalGameEventBus = { - onGameEvent: (event: string, handler: GameEventHandler) => void; - offGameEvent: (event: string, handler: GameEventHandler) => void; + onGameEvent: (event: string, handler: GeneratorEventHandler) => void; + offGameEvent: (event: string, handler: GeneratorEventHandler) => void; } \ No newline at end of file