From 4b00dbfc00889e36e4a2b46226d4e17a690a41d3 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Fri, 5 Jun 2026 00:02:45 +0300 Subject: [PATCH] initial game scripting --- src/blockly/events/gameStart.ts | 17 +++++ src/blockly/events/index.ts | 3 + src/blockly/events/objectTouchedMe.ts | 24 +++++++ src/blockly/events/playerTouchedMe.ts | 18 +++++ src/blockly/fieldTypes/objectType.ts | 11 +++ src/blockly/gameCycle.ts | 3 + src/blockly/generateCode.ts | 28 ++++++++ src/blockly/index.ts | 4 ++ src/blockly/theme.ts | 17 +++++ src/blockly/values/currentObject.ts | 20 ++++++ src/blockly/values/index.ts | 1 + src/components/JoystickView.tsx | 4 +- .../scriptEditor/ScriptEditorView.tsx | 65 ++++++++++------- src/index.scss | 7 ++ src/model/gameFactory.ts | 72 ++++++++++++++++++- src/state/gameState.ts | 30 +++++++- src/state/rootState.ts | 4 +- src/state/worldEditorState.ts | 1 - src/state/worldState.ts | 2 +- src/types/model/object.ts | 1 + src/types/runtime/gameBus.ts | 16 +++++ 21 files changed, 312 insertions(+), 36 deletions(-) create mode 100644 src/blockly/events/gameStart.ts create mode 100644 src/blockly/events/index.ts create mode 100644 src/blockly/events/objectTouchedMe.ts create mode 100644 src/blockly/events/playerTouchedMe.ts create mode 100644 src/blockly/fieldTypes/objectType.ts create mode 100644 src/blockly/gameCycle.ts create mode 100644 src/blockly/generateCode.ts create mode 100644 src/blockly/index.ts create mode 100644 src/blockly/theme.ts create mode 100644 src/blockly/values/currentObject.ts create mode 100644 src/blockly/values/index.ts create mode 100644 src/types/runtime/gameBus.ts diff --git a/src/blockly/events/gameStart.ts b/src/blockly/events/gameStart.ts new file mode 100644 index 0000000..72cdecc --- /dev/null +++ b/src/blockly/events/gameStart.ts @@ -0,0 +1,17 @@ +import * as Blockly from "blockly"; +import { javascriptGenerator } from "blockly/javascript"; + +Blockly.common.defineBlocksWithJsonArray([{ + "type": "game_start_event", + "message0": "On game start", + "nextStatement": null, + "style": "event_hat", + "tooltip": "Runs when the game starts.", + "helpUrl": "" +}]); + +javascriptGenerator.forBlock['game_start_event'] = function (_block, _generator) { + return ''; +}; + +export { }; diff --git a/src/blockly/events/index.ts b/src/blockly/events/index.ts new file mode 100644 index 0000000..96ec612 --- /dev/null +++ b/src/blockly/events/index.ts @@ -0,0 +1,3 @@ +export * from './gameStart'; +export * from './objectTouchedMe'; +export * from './playerTouchedMe'; diff --git a/src/blockly/events/objectTouchedMe.ts b/src/blockly/events/objectTouchedMe.ts new file mode 100644 index 0000000..f38056d --- /dev/null +++ b/src/blockly/events/objectTouchedMe.ts @@ -0,0 +1,24 @@ +import * as Blockly from "blockly"; +import { javascriptGenerator } from "blockly/javascript"; +import { getObjectTypeDropDownOptions } from "../fieldTypes/objectType"; + +Blockly.Blocks['object_touch_start_event'] = { + init(this: Blockly.Block) { + this.appendDummyInput() + .appendField('When') + .appendField( + new Blockly.FieldDropdown(getObjectTypeDropDownOptions), + 'OBJECT_TYPE', + ) + .appendField('touches me'); + this.setStyle('event_hat'); + this.setNextStatement(true); + this.setTooltip('Fires when an object touches me'); + } +}; + +javascriptGenerator.forBlock['object_touch_start_event'] = function (_block, _generator) { + return ''; +}; + +export { }; diff --git a/src/blockly/events/playerTouchedMe.ts b/src/blockly/events/playerTouchedMe.ts new file mode 100644 index 0000000..d25ad2b --- /dev/null +++ b/src/blockly/events/playerTouchedMe.ts @@ -0,0 +1,18 @@ +import * as Blockly from "blockly"; +import { javascriptGenerator } from "blockly/javascript"; + +Blockly.Blocks['player_touch_start_event'] = { + init(this: Blockly.Block) { + this.appendDummyInput() + .appendField('When player touches me'); + this.setStyle('event_hat'); + this.setNextStatement(true); + this.setTooltip('Fires when player touches me'); + } +}; + +javascriptGenerator.forBlock['player_touch_start_event'] = function (_block, _generator) { + return ''; +}; + +export { }; diff --git a/src/blockly/fieldTypes/objectType.ts b/src/blockly/fieldTypes/objectType.ts new file mode 100644 index 0000000..428b2dd --- /dev/null +++ b/src/blockly/fieldTypes/objectType.ts @@ -0,0 +1,11 @@ +import { state } from "../../state/rootState"; +import { toJS } from "mobx"; + +export function getObjectTypeDropDownOptions(): [string, string][] { + const types = Object.values(toJS(state.world.data.objectTypes)); + const values: [string, string][] = [ + ['Any object', '*'], + ...types.map(t => [t.name, t.id] as [string, string]), + ]; + return values; +} diff --git a/src/blockly/gameCycle.ts b/src/blockly/gameCycle.ts new file mode 100644 index 0000000..6893946 --- /dev/null +++ b/src/blockly/gameCycle.ts @@ -0,0 +1,3 @@ +export function startGame() { + +} diff --git a/src/blockly/generateCode.ts b/src/blockly/generateCode.ts new file mode 100644 index 0000000..aea1591 --- /dev/null +++ b/src/blockly/generateCode.ts @@ -0,0 +1,28 @@ +import * as Blockly from "blockly"; +import { javascriptGenerator } from "blockly/javascript"; + +export function generateCode(workspace: Blockly.WorkspaceSvg): string { + let result = ''; + for (const block of workspace.getTopBlocks(true)) { + if (block.type.endsWith('_event')) { + let body = ''; + let next = block.getNextBlock(); + while (next) { + // opt_thisOnly=true prevents scrub_ from also chaining — we do it manually + body += javascriptGenerator.blockToCode(next, true); + next = next.getNextBlock(); + } + body = javascriptGenerator.prefixLines(body, javascriptGenerator.INDENT); + + const args = Array.from(block.getFields()) + .filter(field => field.name !== undefined) + .map(field => `${field.name}: ${JSON.stringify(field.getValue())}`) + .join(', '); + + //TODO use args + + result += `onGameEvent('${block.type.replace(/_event$/, '')}', function(context) {\n${body}});\n`; + } + } + return result; +} diff --git a/src/blockly/index.ts b/src/blockly/index.ts new file mode 100644 index 0000000..1dbdba7 --- /dev/null +++ b/src/blockly/index.ts @@ -0,0 +1,4 @@ +export * from './events'; +export * from './values'; +export * from './theme'; +export * from './generateCode'; diff --git a/src/blockly/theme.ts b/src/blockly/theme.ts new file mode 100644 index 0000000..b1e8bf1 --- /dev/null +++ b/src/blockly/theme.ts @@ -0,0 +1,17 @@ +import * as Blockly from "blockly"; + +export const gameTheme = Blockly.Theme.defineTheme( + 'gameTheme', + { + name: 'GameTheme', + base: Blockly.Themes.Classic, + blockStyles: { + 'event_hat': { + colourPrimary: '#FFAB19', + colourSecondary: '#CF8B17', + colourTertiary: '#CF8B17', + hat: 'cap', + }, + }, + }, +); diff --git a/src/blockly/values/currentObject.ts b/src/blockly/values/currentObject.ts new file mode 100644 index 0000000..4d29544 --- /dev/null +++ b/src/blockly/values/currentObject.ts @@ -0,0 +1,20 @@ +import * as Blockly from "blockly"; +import { javascriptGenerator, Order } from "blockly/javascript"; + +Blockly.Blocks['current_object_value'] = { + init(this: Blockly.Block) { + this.appendEndRowInput('NAME') + .appendField('current object'); + this.setInputsInline(false) + this.setOutput(true, 'String'); + this.setTooltip('asd'); + this.setHelpUrl('asd'); + this.setColour(315); + } +}; + +javascriptGenerator.forBlock['current_object_value'] = function (_block, _generator) { + return ['object.id', Order.NONE]; +}; + +export { }; diff --git a/src/blockly/values/index.ts b/src/blockly/values/index.ts new file mode 100644 index 0000000..ed55ff5 --- /dev/null +++ b/src/blockly/values/index.ts @@ -0,0 +1 @@ +export * from './currentObject'; diff --git a/src/components/JoystickView.tsx b/src/components/JoystickView.tsx index 60f88c3..c443913 100644 --- a/src/components/JoystickView.tsx +++ b/src/components/JoystickView.tsx @@ -2,8 +2,8 @@ import { useEffect, useRef } from "react"; import { create } from "nipplejs"; import { joystickValues } from "../joystickInput"; -// const isTouch = navigator.maxTouchPoints > 0; -const isTouch = true; // debug +const isTouch = navigator.maxTouchPoints > 0; +// const isTouch = true; // debug export function JoystickView() { const moveZoneRef = useRef(null); diff --git a/src/components/scriptEditor/ScriptEditorView.tsx b/src/components/scriptEditor/ScriptEditorView.tsx index 850e8e1..046bc21 100644 --- a/src/components/scriptEditor/ScriptEditorView.tsx +++ b/src/components/scriptEditor/ScriptEditorView.tsx @@ -5,16 +5,19 @@ import * as Blockly from "blockly"; import type { ObjectType } from "../../types"; import './ScriptEditorView.scss'; +import '../../blockly'; +import { gameTheme, generateCode } from '../../blockly'; + const TOOLBOX: Blockly.utils.toolbox.ToolboxDefinition = { kind: 'flyoutToolbox', contents: [ - { kind: 'block', type: 'controls_if' }, - { kind: 'block', type: 'controls_repeat_ext' }, - { kind: 'block', type: 'logic_compare' }, - { kind: 'block', type: 'math_number' }, - { kind: 'block', type: 'math_arithmetic' }, { kind: 'block', type: 'text' }, { kind: 'block', type: 'text_print' }, + { kind: 'block', type: 'game_start_event' }, + { kind: 'block', type: 'object_touch_start_event' }, + { kind: 'block', type: 'player_touch_start_event' }, + { kind: 'block', type: 'current_object_value' }, + ], }; @@ -25,32 +28,46 @@ type ScriptEditorViewProps = { export const ScriptEditorView = observer(function ({ objectType }: ScriptEditorViewProps) { const containerRef = useRef(null); const workspaceRef = useRef(null); - const isLoadingRef = useRef(false); + const objectTypeRef = useRef(objectType); + objectTypeRef.current = objectType; useEffect(() => { if (!containerRef.current) return; - const workspace = Blockly.inject(containerRef.current, { toolbox: TOOLBOX }); + const workspace = Blockly.inject(containerRef.current, { toolbox: TOOLBOX, theme: gameTheme }); workspaceRef.current = workspace; + const listener = (e: Blockly.Events.Abstract) => { + if (e.isUiEvent) + return; + + runInAction(() => { + const current = objectTypeRef.current; + const serialized = JSON.stringify(Blockly.serialization.workspaces.save(workspace)); + current.program = serialized; + try { + current.javascript = generateCode(workspace); + } + catch (err) { + console.log(err); + current.javascript = undefined; + } + console.log(current.javascript); + }); + }; + workspace.addChangeListener(listener); + if (objectType.program) { try { - isLoadingRef.current = true; + Blockly.Events.disable(); Blockly.serialization.workspaces.load(JSON.parse(objectType.program), workspace); } catch { // corrupt program, start fresh } finally { - isLoadingRef.current = false; + Blockly.Events.enable(); } } - const listener = (e: Blockly.Events.Abstract) => { - if (isLoadingRef.current || e.isUiEvent) return; - const serialized = JSON.stringify(Blockly.serialization.workspaces.save(workspace)); - runInAction(() => { objectType.program = serialized; }); - }; - workspace.addChangeListener(listener); - const resizeObserver = new ResizeObserver(() => Blockly.svgResize(workspace)); resizeObserver.observe(containerRef.current); @@ -66,16 +83,16 @@ export const ScriptEditorView = observer(function ({ objectType }: ScriptEditorV const workspace = workspaceRef.current; if (!workspace) return; - isLoadingRef.current = true; - workspace.clear(); - if (objectType.program) { - try { + try { + Blockly.Events.disable(); + workspace.clear(); + if (objectType.program) Blockly.serialization.workspaces.load(JSON.parse(objectType.program), workspace); - } catch { - // corrupt program, start fresh - } + } catch { + // corrupt program, start fresh + } finally { + Blockly.Events.enable(); } - isLoadingRef.current = false; }, [objectType.id]); return
diff --git a/src/index.scss b/src/index.scss index 94956be..65a1aa8 100644 --- a/src/index.scss +++ b/src/index.scss @@ -81,4 +81,11 @@ textarea { * { box-sizing: border-box; +} + +// Blockly appends its dropdown/widget overlays to document.body. +// They need a z-index above .overlay-panels (15000) to be visible. +.blocklyDropDownDiv, +.blocklyWidgetDiv { + z-index: 16000 !important; } \ No newline at end of file diff --git a/src/model/gameFactory.ts b/src/model/gameFactory.ts index 1fd92f2..a871257 100644 --- a/src/model/gameFactory.ts +++ b/src/model/gameFactory.ts @@ -1,17 +1,54 @@ -import type { Game, RuntimeGameScene, RuntimeScene, World } from "../types"; +import type { Game, ObjectType, RuntimeGameScene, RuntimeObjectInstance, RuntimeScene, World } from "../types"; +import type { GameEventHandler, GameEventContext } from "../types/runtime/gameBus"; import { clone } from "../utils"; import { populateRuntimeScene } from "../utils/runtime"; +type OwnedGameEventHandler = { + handler: GameEventHandler; +} + +export class GameEventBus { + private handlers = new Map(); + + on(event: string, handler: GameEventHandler): void { + console.log('subscribing to ' + event, handler); + let set = this.handlers.get(event); + if (!set) { + set = []; + this.handlers.set(event, set); + } + if (!set.some((h) => h.handler === handler)) + set.push({ handler }); + } + + off(event: string, handler: GameEventHandler): void { + console.log('unubscribing from ' + event, handler); + const handlers = this.handlers.get(event); + if (!handlers) + return; + + this.handlers.set(event, handlers.filter((h) => h.handler !== handler)); + } + + emit(event: string, context: GameEventContext): void { + console.log('emitting ' + event); + console.log(Array.from(this.handlers.entries())); + this.handlers.get(event) + ?.forEach((h) => h.handler(context)); + } +} + export class GameFactory { public static create(world: World): Game { const scene = populateRuntimeScene(clone(world.initialScene), world); - return { + const game = { paused: false, time: 0, scene: GameFactory.initGameScene(scene), - } + }; + return game; } public static load(): Game | undefined { @@ -28,4 +65,33 @@ export class GameFactory { private static initGameScene(scene: RuntimeScene): RuntimeGameScene { return scene as RuntimeGameScene; } + + public static evalGameScripts( + world: World, + scene: RuntimeGameScene, + eventBus: GameEventBus, + ): void { + + const internalBus = { + onGameEvent: eventBus.on.bind(eventBus), + offGameEvent: eventBus.off.bind(eventBus), + }; + + try { + Object.values(world.objectTypes) + .filter((ot) => ot.javascript) + .forEach((ot) => { + const otCode = eval(`(function gameScript({onGameEvent, offGameEvent}, object, objectType) {${ot.javascript}})`); + + Object.values(scene.objects) + .filter((o) => o.typeId == ot.id) + .forEach((o) => { + otCode(internalBus, o, ot); + }) + }); + } + catch (err) { + console.log('Error running game script:\n' + err) + } + } } diff --git a/src/state/gameState.ts b/src/state/gameState.ts index 42f218f..912d2ac 100644 --- a/src/state/gameState.ts +++ b/src/state/gameState.ts @@ -2,7 +2,8 @@ import { makeAutoObservable, reaction, toJS } from "mobx"; import type { WorldState } from "./worldState"; import type { Game, Pos3, V3, R3, RuntimeGameScene } from "../types"; import type { CameraProps } from "@react-three/fiber"; -import { GameFactory } from "../model/gameFactory"; +import { GameEventBus, GameFactory } from "../model/gameFactory"; +import type { GameEventContext } from "../types/runtime/gameBus"; export class GameState { private readonly world: WorldState; @@ -11,6 +12,8 @@ export class GameState { public time: number = 0; public scene: RuntimeGameScene; + private eventBus: GameEventBus; + private startAutoSave() { return reaction( () => (this.asGame), @@ -31,13 +34,34 @@ export class GameState { constructor(world: WorldState) { this.world = world; - const game = GameFactory.create(toJS(this.world.data)); + + const rawWorld = toJS(this.world.data); + + const game = GameFactory.create(rawWorld); this.isPaused = game.paused; this.time = game.time; this.scene = game.scene; + const eventBus = new GameEventBus(); + this.eventBus = eventBus; + GameFactory.evalGameScripts(rawWorld, game.scene, eventBus); + + this.emitEvent('game_start'); + this._stopAutoSave = this.startAutoSave(); - makeAutoObservable(this); + makeAutoObservable( + this, + ); + } + + public emitEvent(event: string) { + this.eventBus.emit( + event, + { + world: this.world.data, + scene: this.scene, + }, + ); } public get asGame(): Game { diff --git a/src/state/rootState.ts b/src/state/rootState.ts index 398ae37..2738b75 100644 --- a/src/state/rootState.ts +++ b/src/state/rootState.ts @@ -36,10 +36,10 @@ export class RootState { } public startGame(): void { + state.worldEditor.resetSelection(); if (this.game) this.stopGame(); - this.game = new GameState(this.world), - state.worldEditor.resetSelectedObject(); + this.game = new GameState(this.world); } public stopGame(): void { diff --git a/src/state/worldEditorState.ts b/src/state/worldEditorState.ts index e4e4be6..6057d5c 100644 --- a/src/state/worldEditorState.ts +++ b/src/state/worldEditorState.ts @@ -70,7 +70,6 @@ export class WorldEditorState { public setSelection(value: Selection | undefined): void { this.selection = value; - console.log(JSON.stringify(this.selection)); } public setSelectedObject(value: Omit | undefined): void { diff --git a/src/state/worldState.ts b/src/state/worldState.ts index c814976..9d2b59b 100644 --- a/src/state/worldState.ts +++ b/src/state/worldState.ts @@ -111,7 +111,7 @@ export class WorldState { private saveData(data: World): void { console.log('Saving world...'); const stack = new Error('Saving world...').stack!.split('\n').slice(1); - const { objectTypes, voxelTypes, ...debug } = toJS(data); + const { voxelTypes, initialScene, ...debug } = toJS(data); console.dir({ stack, debug }); WorldFactory.save(toJS(this.data)); } diff --git a/src/types/model/object.ts b/src/types/model/object.ts index d98d491..16c3b9a 100644 --- a/src/types/model/object.ts +++ b/src/types/model/object.ts @@ -7,6 +7,7 @@ export type ObjectType = { name: string; voxels: Voxel[]; program?: string; + javascript?: string; } export type ObjectInstance = { diff --git a/src/types/runtime/gameBus.ts b/src/types/runtime/gameBus.ts new file mode 100644 index 0000000..a59bf9e --- /dev/null +++ b/src/types/runtime/gameBus.ts @@ -0,0 +1,16 @@ +import type { Scene, World } from "../model"; + +export type GameEventContext = { + world: World; + scene: Scene; +} + +export type GameEventHandler = (context: GameEventContext) => void; + +export type GameBus = { + onGameEvent: ( + event: string, + params: Record, + callback: GameEventHandler, + ) => void; +} \ No newline at end of file