initial game scripting

This commit is contained in:
azykov@mail.ru 2026-06-05 00:02:45 +03:00
parent 53e68c2279
commit 4b00dbfc00
No known key found for this signature in database
21 changed files with 312 additions and 36 deletions

View File

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

View File

@ -0,0 +1,3 @@
export * from './gameStart';
export * from './objectTouchedMe';
export * from './playerTouchedMe';

View File

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

View File

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

View File

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

3
src/blockly/gameCycle.ts Normal file
View File

@ -0,0 +1,3 @@
export function startGame() {
}

View File

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

4
src/blockly/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from './events';
export * from './values';
export * from './theme';
export * from './generateCode';

17
src/blockly/theme.ts Normal file
View File

@ -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',
},
},
},
);

View File

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

View File

@ -0,0 +1 @@
export * from './currentObject';

View File

@ -2,8 +2,8 @@ import { useEffect, useRef } from "react";
import { create } from "nipplejs"; import { create } from "nipplejs";
import { joystickValues } from "../joystickInput"; import { joystickValues } from "../joystickInput";
// const isTouch = navigator.maxTouchPoints > 0; const isTouch = navigator.maxTouchPoints > 0;
const isTouch = true; // debug // const isTouch = true; // debug
export function JoystickView() { export function JoystickView() {
const moveZoneRef = useRef<HTMLDivElement>(null); const moveZoneRef = useRef<HTMLDivElement>(null);

View File

@ -5,16 +5,19 @@ import * as Blockly from "blockly";
import type { ObjectType } from "../../types"; import type { ObjectType } from "../../types";
import './ScriptEditorView.scss'; import './ScriptEditorView.scss';
import '../../blockly';
import { gameTheme, generateCode } from '../../blockly';
const TOOLBOX: Blockly.utils.toolbox.ToolboxDefinition = { const TOOLBOX: Blockly.utils.toolbox.ToolboxDefinition = {
kind: 'flyoutToolbox', kind: 'flyoutToolbox',
contents: [ 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' },
{ kind: 'block', type: 'text_print' }, { 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) { export const ScriptEditorView = observer(function ({ objectType }: ScriptEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const workspaceRef = useRef<Blockly.WorkspaceSvg | null>(null); const workspaceRef = useRef<Blockly.WorkspaceSvg | null>(null);
const isLoadingRef = useRef(false); const objectTypeRef = useRef(objectType);
objectTypeRef.current = objectType;
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; 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; 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) { if (objectType.program) {
try { try {
isLoadingRef.current = true; Blockly.Events.disable();
Blockly.serialization.workspaces.load(JSON.parse(objectType.program), workspace); Blockly.serialization.workspaces.load(JSON.parse(objectType.program), workspace);
} catch { } catch {
// corrupt program, start fresh // corrupt program, start fresh
} finally { } 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)); const resizeObserver = new ResizeObserver(() => Blockly.svgResize(workspace));
resizeObserver.observe(containerRef.current); resizeObserver.observe(containerRef.current);
@ -66,16 +83,16 @@ export const ScriptEditorView = observer(function ({ objectType }: ScriptEditorV
const workspace = workspaceRef.current; const workspace = workspaceRef.current;
if (!workspace) return; 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); Blockly.serialization.workspaces.load(JSON.parse(objectType.program), workspace);
} catch { } catch {
// corrupt program, start fresh // corrupt program, start fresh
} finally {
Blockly.Events.enable();
} }
}
isLoadingRef.current = false;
}, [objectType.id]); }, [objectType.id]);
return <div className="script-editor"> return <div className="script-editor">

View File

@ -82,3 +82,10 @@ textarea {
* { * {
box-sizing: border-box; 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;
}

View File

@ -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 { clone } from "../utils";
import { populateRuntimeScene } from "../utils/runtime"; import { populateRuntimeScene } from "../utils/runtime";
type OwnedGameEventHandler = {
handler: GameEventHandler;
}
export class GameEventBus {
private handlers = new Map<string, OwnedGameEventHandler[]>();
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 { export class GameFactory {
public static create(world: World): Game { public static create(world: World): Game {
const scene = populateRuntimeScene(clone(world.initialScene), world); const scene = populateRuntimeScene(clone(world.initialScene), world);
return { const game = {
paused: false, paused: false,
time: 0, time: 0,
scene: GameFactory.initGameScene(scene), scene: GameFactory.initGameScene(scene),
} };
return game;
} }
public static load(): Game | undefined { public static load(): Game | undefined {
@ -28,4 +65,33 @@ export class GameFactory {
private static initGameScene(scene: RuntimeScene): RuntimeGameScene { private static initGameScene(scene: RuntimeScene): RuntimeGameScene {
return scene as 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)
}
}
} }

View File

@ -2,7 +2,8 @@ import { makeAutoObservable, reaction, toJS } from "mobx";
import type { WorldState } from "./worldState"; import type { WorldState } from "./worldState";
import type { Game, Pos3, V3, R3, RuntimeGameScene } from "../types"; import type { Game, Pos3, V3, R3, RuntimeGameScene } from "../types";
import type { CameraProps } from "@react-three/fiber"; 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 { export class GameState {
private readonly world: WorldState; private readonly world: WorldState;
@ -11,6 +12,8 @@ export class GameState {
public time: number = 0; public time: number = 0;
public scene: RuntimeGameScene; public scene: RuntimeGameScene;
private eventBus: GameEventBus;
private startAutoSave() { private startAutoSave() {
return reaction( return reaction(
() => (this.asGame), () => (this.asGame),
@ -31,13 +34,34 @@ export class GameState {
constructor(world: WorldState) { constructor(world: WorldState) {
this.world = world; 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.isPaused = game.paused;
this.time = game.time; this.time = game.time;
this.scene = game.scene; 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(); 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 { public get asGame(): Game {

View File

@ -36,10 +36,10 @@ export class RootState {
} }
public startGame(): void { public startGame(): void {
state.worldEditor.resetSelection();
if (this.game) if (this.game)
this.stopGame(); this.stopGame();
this.game = new GameState(this.world), this.game = new GameState(this.world);
state.worldEditor.resetSelectedObject();
} }
public stopGame(): void { public stopGame(): void {

View File

@ -70,7 +70,6 @@ export class WorldEditorState {
public setSelection(value: Selection | undefined): void { public setSelection(value: Selection | undefined): void {
this.selection = value; this.selection = value;
console.log(JSON.stringify(this.selection));
} }
public setSelectedObject(value: Omit<SelectedObject, 'type'> | undefined): void { public setSelectedObject(value: Omit<SelectedObject, 'type'> | undefined): void {

View File

@ -111,7 +111,7 @@ export class WorldState {
private saveData(data: World): void { private saveData(data: World): void {
console.log('Saving world...'); console.log('Saving world...');
const stack = new Error('Saving world...').stack!.split('\n').slice(1); 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 }); console.dir({ stack, debug });
WorldFactory.save(toJS(this.data)); WorldFactory.save(toJS(this.data));
} }

View File

@ -7,6 +7,7 @@ export type ObjectType = {
name: string; name: string;
voxels: Voxel[]; voxels: Voxel[];
program?: string; program?: string;
javascript?: string;
} }
export type ObjectInstance = { export type ObjectInstance = {

View File

@ -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<string, unknown>,
callback: GameEventHandler,
) => void;
}