waitable blockly scripts. new waitForSeconds block

This commit is contained in:
azykov@mail.ru 2026-06-11 11:05:31 +03:00
parent 830c2bdde6
commit 3bdde094f8
No known key found for this signature in database
10 changed files with 134 additions and 18 deletions

View File

@ -18,5 +18,14 @@ export default defineConfig([
languageOptions: { languageOptions: {
globals: globals.browser, 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: '*' },
]
},
}, },
]) ])

View File

@ -5,7 +5,7 @@ Blockly.Blocks['console_log_action'] = {
init(this: Blockly.Block) { init(this: Blockly.Block) {
this.appendValueInput('VALUE') this.appendValueInput('VALUE')
.setAlign(Blockly.inputs.Align.RIGHT) .setAlign(Blockly.inputs.Align.RIGHT)
.setCheck('ObjectType') // .setCheck('String')
.appendField('print'); .appendField('print');
this.setInputsInline(false) this.setInputsInline(false)
this.setPreviousStatement(true, null); this.setPreviousStatement(true, null);

View File

@ -1,2 +1,3 @@
export * from './consoleLog'; export * from './consoleLog';
export * from './physics'; export * from './physics';
export * from './waitSeconds';

View File

@ -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 { };

View File

@ -12,7 +12,7 @@ export function generateCode(workspace: Blockly.WorkspaceSvg): string {
body += javascriptGenerator.blockToCode(next, true); body += javascriptGenerator.blockToCode(next, true);
next = next.getNextBlock(); next = next.getNextBlock();
} }
body = javascriptGenerator.prefixLines(body, javascriptGenerator.INDENT); body = javascriptGenerator.prefixLines(body, javascriptGenerator.INDENT.repeat(2));
// const args = Array.from(block.getFields()) // const args = Array.from(block.getFields())
// .filter(field => field.name !== undefined) // .filter(field => field.name !== undefined)
@ -22,13 +22,11 @@ export function generateCode(workspace: Blockly.WorkspaceSvg): string {
//TODO use args //TODO use args
result += `onGameEvent( result += `onGameEvent(
'${block.type.replace(/_event$/, '')}', '${block.type.replace(/_event$/, '')}',
function(context) { function*(context) {
// console.dir({context, api, object, objectType});
if (context.object && (context.object.id !== object.id)) if (context.object && (context.object.id !== object.id))
return; return;
${body} ${body} },
}
); );
`; `;
} }

View File

@ -45,8 +45,8 @@ const TOOLBOX: Blockly.utils.toolbox.ToolboxDefinition = {
colour: '20', colour: '20',
contents: [ contents: [
{ kind: 'block', type: 'console_log_action' }, { kind: 'block', type: 'console_log_action' },
{ kind: 'block', type: 'text_print' },
{ kind: 'block', type: 'physics_apply_impulse_action' }, { kind: 'block', type: 'physics_apply_impulse_action' },
{ kind: 'block', type: 'wait_seconds_action' },
], ],
}, },
{ {

View File

@ -0,0 +1,63 @@
export type YieldCommand =
| { type: 'waitSeconds'; seconds: number }
| { type: 'waitFrames'; frames: number }
| { type: 'waitUntil'; condition: () => boolean };
type Coroutine =
& { gen: Generator<YieldCommand, void, void> }
& (| { 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<YieldCommand, void, void>): 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<YieldCommand, void, void>, 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 };
}
}
}

View File

@ -1,8 +1,9 @@
import type { Game, RuntimeGameScene, RuntimeScene, World } from "../types"; 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 { clone } from "../utils";
import { populateRuntimeScene } from "../utils/runtime"; import { populateRuntimeScene } from "../utils/runtime";
import { ObjectApi } from "../utils/runtime/objectApi"; import { ObjectApi } from "../utils/runtime/objectApi";
import type { GameScheduler, YieldCommand } from "./GameScheduler";
export class GameEventBus { export class GameEventBus {
private handlers = new Map<string, GameEventHandler[]>(); private handlers = new Map<string, GameEventHandler[]>();
@ -66,25 +67,41 @@ export class GameFactory {
world: World, world: World,
scene: RuntimeGameScene, scene: RuntimeGameScene,
eventBus: GameEventBus, eventBus: GameEventBus,
scheduler: GameScheduler,
): void { ): void {
const handlerWrappers = new WeakMap<Function, (ctx: GameEventContext) => 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 = { const internalBus: InternalGameEventBus = {
onGameEvent: eventBus.on.bind(eventBus), onGameEvent(event, genFn) {
offGameEvent: eventBus.off.bind(eventBus), const wrapper: GameEventHandler = (ctx) => scheduler.startCoroutine(genFn(ctx) as Generator<YieldCommand, void, void>);
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 { try {
Object.values(world.objectTypes) Object.values(world.objectTypes)
.filter((ot) => ot.javascript) .filter((ot) => ot.javascript)
.forEach((ot) => { .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) Object.values(scene.objects)
.filter((o) => o.typeId == ot.id) .filter((o) => o.typeId == ot.id)
.forEach((o) => { .forEach((o) => {
const api = new ObjectApi(o, ot, world, scene); 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 });
}) })
}); });
} }

View File

@ -3,6 +3,7 @@ import type { WorldState } from "./worldState";
import type { Game, Pos3, RuntimeGameScene } from "../types"; import type { Game, Pos3, RuntimeGameScene } from "../types";
import type { CameraProps } from "@react-three/fiber"; import type { CameraProps } from "@react-three/fiber";
import { GameEventBus, GameFactory } from "../model/gameFactory"; import { GameEventBus, GameFactory } from "../model/gameFactory";
import { GameScheduler } from "../model/GameScheduler";
import type { GameEventContext } from "../types/runtime/gameBus"; import type { GameEventContext } from "../types/runtime/gameBus";
export class GameState { export class GameState {
@ -14,6 +15,7 @@ export class GameState {
public scene: RuntimeGameScene; public scene: RuntimeGameScene;
private eventBus: GameEventBus; private eventBus: GameEventBus;
private scheduler: GameScheduler;
private startAutoSave() { private startAutoSave() {
return reaction( return reaction(
@ -46,11 +48,12 @@ export class GameState {
const eventBus = new GameEventBus(); const eventBus = new GameEventBus();
this.eventBus = eventBus; this.eventBus = eventBus;
this.scheduler = new GameScheduler();
this._stopAutoSave = this.startAutoSave(); this._stopAutoSave = this.startAutoSave();
makeAutoObservable(this); makeAutoObservable(this);
GameFactory.evalGameScripts(rawWorld, this.scene, eventBus); GameFactory.evalGameScripts(rawWorld, this.scene, eventBus, this.scheduler);
} }
public emitEvent(event: string, context: Omit<GameEventContext, 'world' | 'scene'> = {}) { public emitEvent(event: string, context: Omit<GameEventContext, 'world' | 'scene'> = {}) {
@ -126,6 +129,7 @@ export class GameState {
if (this.isPaused) if (this.isPaused)
return; return;
this.scheduler.tick(deltaTime);
this.time += deltaTime; this.time += deltaTime;
if (this.isFirstTick) { if (this.isFirstTick) {

View File

@ -8,7 +8,9 @@ export type GameEventContext = {
export type GameEventHandler = (context: GameEventContext) => void; export type GameEventHandler = (context: GameEventContext) => void;
export type GeneratorEventHandler = (context: GameEventContext) => Generator<unknown, void, void>;
export type InternalGameEventBus = { export type InternalGameEventBus = {
onGameEvent: (event: string, handler: GameEventHandler) => void; onGameEvent: (event: string, handler: GeneratorEventHandler) => void;
offGameEvent: (event: string, handler: GameEventHandler) => void; offGameEvent: (event: string, handler: GeneratorEventHandler) => void;
} }