diff --git a/webclient/src/blockly/actions/physics/index.ts b/webclient/src/blockly/actions/physics/index.ts index 5e0541a..d6353f5 100644 --- a/webclient/src/blockly/actions/physics/index.ts +++ b/webclient/src/blockly/actions/physics/index.ts @@ -1 +1,3 @@ -export * from './applyImpulse'; \ No newline at end of file +export * from './applyImpulse'; +export * from './moveInDirection'; +export * from './moveToPosition'; \ No newline at end of file diff --git a/webclient/src/blockly/actions/physics/moveInDirection.ts b/webclient/src/blockly/actions/physics/moveInDirection.ts new file mode 100644 index 0000000..0bf860c --- /dev/null +++ b/webclient/src/blockly/actions/physics/moveInDirection.ts @@ -0,0 +1,30 @@ +import * as Blockly from "blockly"; +import { javascriptGenerator, Order } from "blockly/javascript"; + +Blockly.Blocks['move_in_direction_action'] = { + init(this: Blockly.Block) { + this.appendDummyInput() + .appendField('Move me in direction'); + this.appendValueInput('DIRECTION') + .setCheck('DPos'); + this.appendDummyInput() + .appendField('for') + .appendField(new Blockly.FieldNumber(5, 0.01, 10000, 0.1), 'DISTANCE') + .appendField('with speed') + .appendField(new Blockly.FieldNumber(5, 0.01, 1000, 0.1), 'SPEED'); + this.setInputsInline(true); + this.setPreviousStatement(true, null); + this.setNextStatement(true, null); + this.setTooltip('Move this object in the given direction for the given distance at the given speed (units/sec), blocking until done'); + this.setColour(20); + } +}; + +javascriptGenerator.forBlock['move_in_direction_action'] = function (block, generator) { + const direction = generator.valueToCode(block, 'DIRECTION', Order.ATOMIC) || '[0,0,1]'; + const distance = block.getFieldValue('DISTANCE'); + const speed = block.getFieldValue('SPEED'); + return `yield helpers.actions.MoveInDirection(${direction}, ${distance}, ${speed});\n`; +}; + +export { }; diff --git a/webclient/src/blockly/actions/physics/moveToPosition.ts b/webclient/src/blockly/actions/physics/moveToPosition.ts new file mode 100644 index 0000000..479dbf1 --- /dev/null +++ b/webclient/src/blockly/actions/physics/moveToPosition.ts @@ -0,0 +1,27 @@ +import * as Blockly from "blockly"; +import { javascriptGenerator, Order } from "blockly/javascript"; + +Blockly.Blocks['move_to_position_action'] = { + init(this: Blockly.Block) { + this.appendDummyInput() + .appendField('Move me to position'); + this.appendValueInput('TARGET_POSITION') + .setCheck('Pos'); + this.appendDummyInput() + .appendField('with speed') + .appendField(new Blockly.FieldNumber(5, 0.01, 1000, 0.1), 'SPEED'); + this.setInputsInline(true); + this.setPreviousStatement(true, null); + this.setNextStatement(true, null); + this.setTooltip('Move this object to the target position at the given speed (units/sec), blocking until arrived'); + this.setColour(20); + } +}; + +javascriptGenerator.forBlock['move_to_position_action'] = function (block, generator) { + const targetPosition = generator.valueToCode(block, 'TARGET_POSITION', Order.ATOMIC) || '[0,0,0]'; + const speed = block.getFieldValue('SPEED'); + return `yield helpers.actions.MoveToPosition(${targetPosition}, ${speed});\n`; +}; + +export { }; diff --git a/webclient/src/blockly/generateCode.ts b/webclient/src/blockly/generateCode.ts index 98de00e..246bd74 100644 --- a/webclient/src/blockly/generateCode.ts +++ b/webclient/src/blockly/generateCode.ts @@ -2,6 +2,7 @@ import * as Blockly from "blockly"; import { javascriptGenerator } from "blockly/javascript"; export function generateCode(workspace: Blockly.WorkspaceSvg): string { + javascriptGenerator.init(workspace); let result = ''; for (const block of workspace.getTopBlocks(true)) { if (block.type.endsWith('_event')) { @@ -21,9 +22,11 @@ export function generateCode(workspace: Blockly.WorkspaceSvg): string { //TODO use args + const eventName = block.type.replace(/_event$/, ''); + result += `eventBus.on( - '${block.type.replace(/_event$/, '')}', - function*(context) { + '${eventName}', + function* handle_${eventName}(context) { if (context.object && (context.object.id !== thisObject.id)) return; ${body} }, diff --git a/webclient/src/blockly/index.ts b/webclient/src/blockly/index.ts index 66ab015..79a6026 100644 --- a/webclient/src/blockly/index.ts +++ b/webclient/src/blockly/index.ts @@ -4,3 +4,4 @@ export * from './actions'; export * from './theme'; export * from './generateCode'; export * from './fieldTypes'; +export * from './loops'; diff --git a/webclient/src/blockly/loops/index.ts b/webclient/src/blockly/loops/index.ts new file mode 100644 index 0000000..ddbb7ba --- /dev/null +++ b/webclient/src/blockly/loops/index.ts @@ -0,0 +1 @@ +export * from './repeatForever'; diff --git a/webclient/src/blockly/loops/repeatForever.ts b/webclient/src/blockly/loops/repeatForever.ts new file mode 100644 index 0000000..e0f9fb0 --- /dev/null +++ b/webclient/src/blockly/loops/repeatForever.ts @@ -0,0 +1,22 @@ +import * as Blockly from "blockly"; +import { javascriptGenerator } from "blockly/javascript"; + +Blockly.Blocks['loop_forever'] = { + init(this: Blockly.Block) { + this.appendDummyInput() + .appendField('Repeat forever'); + this.appendStatementInput('BODY') + .setCheck(null); + this.setPreviousStatement(true, null); + this.setNextStatement(true, null); + this.setTooltip('Repeat the enclosed actions forever'); + this.setColour(120); + } +}; + +javascriptGenerator.forBlock['loop_forever'] = function (block, generator) { + const body = generator.statementToCode(block, 'BODY'); + return `while (true) {\n${body}${generator.INDENT}yield helpers.actions.WaitFrames(1);\n}\n`; +}; + +export { }; diff --git a/webclient/src/blockly/values/directionToPosition.ts b/webclient/src/blockly/values/directionToPosition.ts index 337e8dd..2064545 100644 --- a/webclient/src/blockly/values/directionToPosition.ts +++ b/webclient/src/blockly/values/directionToPosition.ts @@ -16,7 +16,7 @@ Blockly.Blocks['direction_to_position_value'] = { javascriptGenerator.forBlock['direction_to_position_value'] = function (block, generator) { const targetValue = generator.valueToCode(block, 'TARGET', Order.ATOMIC); - return [`((a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]])(${targetValue},object.position)`, Order.NONE]; + return [`(t=>{const p=helpers.positionOfObject(thisObject); const dir = [t[0]-p[0],t[1]-p[1],t[2]-p[2]]; /* helpers.debug.show('${block.id}.targetPosition', ${targetValue}); helpers.debug.show('${block.id}.result', dir); helpers.debug.show('${block.id}.thisObject.position', p); */ return dir;})(${targetValue})`, Order.NONE]; }; export { }; diff --git a/webclient/src/blockly/values/positionOfObject.ts b/webclient/src/blockly/values/positionOfObject.ts index a19dfb2..78bed7f 100644 --- a/webclient/src/blockly/values/positionOfObject.ts +++ b/webclient/src/blockly/values/positionOfObject.ts @@ -16,7 +16,7 @@ Blockly.Blocks['position_of_object_value'] = { javascriptGenerator.forBlock['position_of_object_value'] = function (block, generator) { const targetValue = generator.valueToCode(block, 'TARGET', Order.ATOMIC); - return [`${targetValue}.position`, Order.NONE]; + return [`helpers.positionOfObject(${targetValue})`, Order.NONE]; }; export { }; diff --git a/webclient/src/components/CharacterView.tsx b/webclient/src/components/CharacterView.tsx index 11f5b9f..bea6ae6 100644 --- a/webclient/src/components/CharacterView.tsx +++ b/webclient/src/components/CharacterView.tsx @@ -47,8 +47,8 @@ export const PlayerObjectView = observer(function ({ object, objectType }: Playe lookAtY: centerY, }; }, [object.cache.boundingBox]); - - const yawRef = useRef(0); + + const yawRef = useRef(object.rotation[1]); const pitchRef = useRef(0); const mouseRef = useRef({ x: 0, y: 0 }); const jumpPressedRef = useRef(false); diff --git a/webclient/src/components/DebugLabel.tsx b/webclient/src/components/DebugLabel.tsx new file mode 100644 index 0000000..3f34983 --- /dev/null +++ b/webclient/src/components/DebugLabel.tsx @@ -0,0 +1,67 @@ +import { Html } from "@react-three/drei"; +import { useFrame } from "@react-three/fiber"; +import { useRef } from "react"; +import * as THREE from "three"; +import type { RuntimeObjectInstance } from "../types"; +import { debugLabelsStore } from "../state/debugLabelsStore"; +import { state } from "../state"; +import { observer } from "mobx-react-lite"; + +export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstance; }) { + const groupRef = useRef(null); + + const rb = obj.rb(); + const texts = [ + `${state.world.getObjectTypeById(obj.typeId)?.name} ${obj.id}`, + `position: ${obj.position}`, + `rotation: ${obj.rotation}`, + ...Object.entries(debugLabelsStore.getAll(obj.id)) + .map(([k, v]) => `${k}: ${v}`), + ]; + + if (rb) { + try { + const t = rb.translation(); + texts.push(...[ + `rb position: ${[t.x, t.y, t.z]}`, + ]); + } catch { + texts.push('rb: stale handle'); + } + } + + useFrame(() => { + if (!groupRef.current) + return; + + const [px, py, pz] = obj.position; + // const bb = obj.cache.boundingBox; + // const halfHeight = bb ? (bb.max[1] - bb.min[1]) / 2 : 1; + // groupRef.current.position.set(px, py + halfHeight + 0.5, pz); + groupRef.current.position.set(px, py, pz); + }); + + const text = texts.join('\n'); + if (!text) + return null; + + return ( + + +
+ {text} +
+ +
+ ); +}); diff --git a/webclient/src/components/DebugLabelsView.tsx b/webclient/src/components/DebugLabelsView.tsx new file mode 100644 index 0000000..340a622 --- /dev/null +++ b/webclient/src/components/DebugLabelsView.tsx @@ -0,0 +1,25 @@ +import { useEffect } from "react"; +import { observer } from "mobx-react-lite"; +import { debugLabelsStore } from "../state/debugLabelsStore"; +import { DebugLabel } from "./DebugLabel"; +import type { RuntimeScene } from "../types"; + +export const DebugLabelsView = observer(function ({ scene }: { scene: RuntimeScene }) { + useEffect( + () => { + return () => { debugLabelsStore.clear(); }; + }, + [], + ); + + return <> + { + Object.entries(scene.objects) + .map(([objectId, obj]) => { + return ; + }) + } + ; +}); + + diff --git a/webclient/src/components/GameView.tsx b/webclient/src/components/GameView.tsx index 32b30cf..e6c0e32 100644 --- a/webclient/src/components/GameView.tsx +++ b/webclient/src/components/GameView.tsx @@ -1,5 +1,6 @@ import { observer } from "mobx-react-lite"; import { SceneView } from "./SceneView"; +import { DebugLabelsView } from "./DebugLabelsView"; import { state } from "../state"; import { useFrame } from "@react-three/fiber"; import { Suspense } from "react"; diff --git a/webclient/src/components/ObjectEditorView.tsx b/webclient/src/components/ObjectEditorView.tsx index 6f814aa..a31324b 100644 --- a/webclient/src/components/ObjectEditorView.tsx +++ b/webclient/src/components/ObjectEditorView.tsx @@ -1,7 +1,9 @@ import { observer } from "mobx-react-lite"; +import { runInAction } from "mobx"; import type { ObjectInstance, R3, Runtime } from "../types"; import { useMemo, useRef, type RefObject } from "react"; import type { Group } from "three"; +import { Euler, Quaternion, Vector3 } from "three"; import { TransformControls, useHelper } from "@react-three/drei"; import { BoxHelper } from "three"; import { state } from "../state"; @@ -16,12 +18,13 @@ type ObjectEditorViewProps = { type SelectionOverlayProps = { objectId: string; ref: RefObject; + onTransformChange: () => void; onTransformEnd: () => void; } // Separate observer so only the 2 affected instances (selected/deselected) // re-render on selection change, not all N objects in the scene. -const SelectionOverlay = observer(function ({ objectId, ref, onTransformEnd }: SelectionOverlayProps) { +const SelectionOverlay = observer(function ({ objectId, ref, onTransformChange, onTransformEnd }: SelectionOverlayProps) { const isSelected = state.worldEditor.isEnabled && state.worldEditor.selection?.type === 'object' && state.worldEditor.selection.id === objectId; @@ -45,6 +48,7 @@ const SelectionOverlay = observer(function ({ objectId, ref, onTransformEnd }: S } mode={selectionMode} + onChange={onTransformChange} onMouseUp={onTransformEnd} /> ); @@ -72,21 +76,38 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE } } + function handleTransformChange() { + const group = dataRef.current?.group; + if (!group) return; + const pos = group.getWorldPosition(new Vector3()); + const quat = group.getWorldQuaternion(new Quaternion()); + const euler = new Euler().setFromQuaternion(quat); + runInAction(() => { + object.position = pos.toArray() as R3; + object.rotation = [euler.x, euler.y, euler.z]; + }); + } + function handleTransformEnd() { const group = dataRef.current?.group; - if (group) - state.worldEditor.setObjectTransform( - object.id, - group.position.toArray(), - group.rotation.toArray().slice(0, 3) as R3, - group.scale.toArray(), - ); + if (!group) return; + const pos = group.getWorldPosition(new Vector3()); + const quat = group.getWorldQuaternion(new Quaternion()); + const euler = new Euler().setFromQuaternion(quat); + const scale = group.getWorldScale(new Vector3()); + state.worldEditor.setObjectTransform( + object.id, + pos.toArray(), + [euler.x, euler.y, euler.z], + scale.toArray(), + ); } return (<> diff --git a/webclient/src/components/SceneView.tsx b/webclient/src/components/SceneView.tsx index 5192664..4db751e 100644 --- a/webclient/src/components/SceneView.tsx +++ b/webclient/src/components/SceneView.tsx @@ -1,7 +1,8 @@ import { observer } from "mobx-react-lite"; -import type { RuntimeScene } from "../types"; +import type { RuntimeGameObjectInstance, RuntimeScene } from "../types"; import { GameObjectView } from "./GameObjectView"; import { ObjectEditorView } from "./ObjectEditorView"; +import { DebugLabelsView } from "./DebugLabelsView"; type SceneViewProps = { scene: RuntimeScene, @@ -14,10 +15,11 @@ export const SceneView = observer(function (props: SceneViewProps) { { Object.values(props.scene.objects).map((obj) => ( - props.editMode - ? - : + !props.editMode + ? + : )) } + ); }); diff --git a/webclient/src/components/ThreeView.tsx b/webclient/src/components/ThreeView.tsx index 46bd914..4002645 100644 --- a/webclient/src/components/ThreeView.tsx +++ b/webclient/src/components/ThreeView.tsx @@ -7,7 +7,7 @@ import { GameView } from './GameView'; import { SceneEditorView } from './SceneEditorView'; import { JoystickView } from './JoystickView'; import type { RefObject } from 'react'; -import { IconPlayerPlayFilled, IconPlayerPauseFilled, IconPlayerStopFilled } from '@tabler/icons-react'; +import { IconPlayerPlayFilled, IconPlayerPauseFilled, IconPlayerStopFilled, IconPlayerSkipForwardFilled } from '@tabler/icons-react'; import { RenderInfoUpdater } from './renderInfo'; export const ThreeView = observer(function () { @@ -37,7 +37,10 @@ export const ThreeView = observer(function () { { state.game!.isPaused - ? + ? <> + + + : } diff --git a/webclient/src/components/scriptEditor/ScriptEditorView.scss b/webclient/src/components/scriptEditor/ScriptEditorView.scss index 30c15f8..061aa4c 100644 --- a/webclient/src/components/scriptEditor/ScriptEditorView.scss +++ b/webclient/src/components/scriptEditor/ScriptEditorView.scss @@ -4,8 +4,80 @@ display: flex; flex-direction: column; + .script-editor-toolbar { + display: flex; + gap: 6px; + padding: 4px 6px; + background: #2a2a2a; + + button { + padding: 2px 10px; + font-size: 12px; + cursor: pointer; + } + } + .blockly-container { flex: 1; min-height: 0; } + + .script-editor-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + + .script-editor-popup { + background: #1e1e1e; + color: #d4d4d4; + border: 1px solid #444; + border-radius: 6px; + width: 70vw; + max-height: 75vh; + display: flex; + flex-direction: column; + overflow: hidden; + + .script-editor-popup-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: #2d2d2d; + border-bottom: 1px solid #444; + font-size: 13px; + font-weight: bold; + + .script-editor-popup-actions { + display: flex; + gap: 8px; + align-items: center; + } + + button { + background: none; + border: none; + color: #aaa; + cursor: pointer; + font-size: 13px; + line-height: 1; + &:hover { color: #fff; } + } + } + + .script-editor-popup-content { + flex: 1; + overflow: auto; + margin: 0; + padding: 12px; + font-size: 12px; + font-family: monospace; + white-space: pre; + } + } } \ No newline at end of file diff --git a/webclient/src/components/scriptEditor/ScriptEditorView.tsx b/webclient/src/components/scriptEditor/ScriptEditorView.tsx index a88a039..1280672 100644 --- a/webclient/src/components/scriptEditor/ScriptEditorView.tsx +++ b/webclient/src/components/scriptEditor/ScriptEditorView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { runInAction } from "mobx"; import * as Blockly from "blockly"; @@ -39,12 +39,22 @@ const TOOLBOX: Blockly.utils.toolbox.ToolboxDefinition = { { kind: 'block', type: 'player_object_value' }, ], }, + { + kind: 'category', + name: 'Loops', + colour: '50', + contents: [ + { kind: 'block', type: 'loop_forever' }, + ], + }, { kind: 'category', name: 'Actions', colour: '20', contents: [ { kind: 'block', type: 'console_log_action' }, + { kind: 'block', type: 'move_to_position_action' }, + { kind: 'block', type: 'move_in_direction_action' }, { kind: 'block', type: 'physics_apply_impulse_action' }, { kind: 'block', type: 'wait_seconds_action' }, ], @@ -65,6 +75,7 @@ type ScriptEditorViewProps = { } export const ScriptEditorView = observer(function ({ objectType }: ScriptEditorViewProps) { + const [popup, setPopup] = useState<{ title: string; content: string } | null>(null); const containerRef = useRef(null); const workspaceRef = useRef(null); const objectTypeRef = useRef(objectType); @@ -86,8 +97,6 @@ export const ScriptEditorView = observer(function ({ objectType }: ScriptEditorV const serialized = JSON.stringify(Blockly.serialization.workspaces.save(workspace)); current.program = serialized; - console.log(serialized); - try { current.javascript = generateCode(workspace); } @@ -95,7 +104,6 @@ export const ScriptEditorView = observer(function ({ objectType }: ScriptEditorV console.log(err); current.javascript = undefined; } - console.log(current.javascript); }); }; workspace.addChangeListener(listener); @@ -139,7 +147,34 @@ export const ScriptEditorView = observer(function ({ objectType }: ScriptEditorV } }, [objectType.id, objectType.program]); + const showProgram = () => { + const raw = objectType.program; + setPopup({ title: 'Program JSON', content: raw ? JSON.stringify(JSON.parse(raw), null, 2) : '(empty)' }); + }; + + const showJavascript = () => { + setPopup({ title: 'JavaScript', content: objectType.javascript ?? '(empty)' }); + }; + return
+
+ + +
+ {popup && ( +
setPopup(null)}> +
e.stopPropagation()}> +
+ {popup.title} +
+ + +
+
+
{popup.content}
+
+
+ )}
; }); diff --git a/webclient/src/model/gameFactory.ts b/webclient/src/model/gameFactory.ts index 9e76c27..2f95837 100644 --- a/webclient/src/model/gameFactory.ts +++ b/webclient/src/model/gameFactory.ts @@ -4,6 +4,8 @@ import { clone } from "../utils"; import { populateRuntimeScene } from "../utils/runtime"; import { ScriptGameEventBus } from "../scripts/eventBus"; import * as ScriptActions from "../scripts/actions"; +import { positionOfObject } from "../scripts/positionOfObject"; +import { debugLabelsStore } from "../state/debugLabelsStore"; import type { ScriptCoroutines } from "../scripts/coroutines"; // // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -51,6 +53,7 @@ export class GameFactory { const eventBus = new ScriptGameEventBus(coroutines); const scriptHelpers = { + positionOfObject, // convert all ScriptActions imported to a structure factory(coroutine): // { // WaitSeconds: (seconds) => new WaitSecondsScriptAction(coroutine, seconds), @@ -71,7 +74,7 @@ export class GameFactory { .filter((ot) => ot.javascript) .forEach((ot) => { try { - const javascript = `(function gameScript(eventBus, helpers, thisObject, thisObjectType) {${ot.javascript}})`; + const javascript = `(function gameScript(eventBus, helpers, thisObject, thisObjectType) {${ot.javascript}})\n//# sourceURL=GameScript:${ot.name}.js`; const gameScript = eval(javascript); Object.values(scene.objects) @@ -86,16 +89,20 @@ export class GameFactory { const localScriptHelpers = { ...scriptHelpers, actions: scriptHelpers.actions({ thisObject: o, thisObjectType: ot }), + debug: { + show: (key: string, text: string) => debugLabelsStore.setOne(o.id, key, text), + clear: (key: string) => debugLabelsStore.deleteOne(o.id, key), + }, }; gameScript(eventBus, localScriptHelpers, o, ot); } catch (err) { - console.log('Error running object instance script:\n' + err); + console.error(`[GameScript:${ot.name}] Error running script for object ${o.id}:`, err); } }); } catch (err) { - console.log('Error evaluating object type script:\n' + err); + console.error(`[GameScript:${ot.name}] Error evaluating script:`, err); } }); diff --git a/webclient/src/scripts/actions/index.ts b/webclient/src/scripts/actions/index.ts index ff4e1ec..8d48896 100644 --- a/webclient/src/scripts/actions/index.ts +++ b/webclient/src/scripts/actions/index.ts @@ -2,3 +2,5 @@ export * from './waitConditionAction'; export * from './waitFramesAction'; export * from './waitSecondsAction'; export * from './applyImpulseAction'; +export * from './moveToPositionAction'; +export * from './moveInDirectionAction'; diff --git a/webclient/src/scripts/actions/moveInDirectionAction.ts b/webclient/src/scripts/actions/moveInDirectionAction.ts new file mode 100644 index 0000000..b83924f --- /dev/null +++ b/webclient/src/scripts/actions/moveInDirectionAction.ts @@ -0,0 +1,44 @@ +import { ScriptAction, type ScriptCoroutineContext, type V3 } from "../../types"; + +const EPS = 0.05; + +export class MoveInDirectionScriptAction extends ScriptAction { + + private direction: V3; + private distance: number; + private speed: number; + private traveled: number = 0; + + constructor(coroutine: ScriptCoroutineContext, direction: V3, distance: number, speed: number) { + super(coroutine); + const len = Math.sqrt(direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2); + this.direction = len > 1e-6 + ? [direction[0] / len, direction[1] / len, direction[2] / len] + : [0, 0, 0]; + this.distance = distance; + this.speed = speed; + } + + public tick(deltaTime: number): boolean { + const rb = this.coroutine.thisObject.rb(); + if (!rb) + return true; + + const remaining = this.distance - this.traveled; + if (remaining < EPS) + return true; + + const step = Math.min(this.speed * deltaTime, remaining); + const { x, y, z } = rb.translation(); + + rb.setTranslation({ + x: x + this.direction[0] * step, + y: y + this.direction[1] * step, + z: z + this.direction[2] * step, + }, true); + rb.setLinvel({ x: 0, y: 0, z: 0 }, true); + + this.traveled += step; + return this.traveled >= this.distance; + } +} diff --git a/webclient/src/scripts/actions/moveToPositionAction.ts b/webclient/src/scripts/actions/moveToPositionAction.ts new file mode 100644 index 0000000..f4745b5 --- /dev/null +++ b/webclient/src/scripts/actions/moveToPositionAction.ts @@ -0,0 +1,44 @@ +import { ScriptAction, type ScriptCoroutineContext, type V3 } from "../../types"; + +export class MoveToPositionScriptAction extends ScriptAction { + + private targetPosition: V3; + private speed: number; + + constructor(coroutine: ScriptCoroutineContext, targetPosition: V3, speed: number) { + super(coroutine); + this.targetPosition = targetPosition; + this.speed = speed; + } + + public tick(deltaTime: number): boolean { + const rb = this.coroutine.thisObject.rb(); + if (!rb) + return true; + + const { x: px, y: py, z: pz } = rb.translation(); + const dx = this.targetPosition[0] - px; + const dy = this.targetPosition[1] - py; + const dz = this.targetPosition[2] - pz; + const distSq = dx * dx + dy * dy + dz * dz; + + if (distSq < 0.0001) { + rb.setLinvel({ x: 0, y: 0, z: 0 }, true); + return true; + } + + const dist = Math.sqrt(distSq); + const step = this.speed * deltaTime; + + if (step >= dist) { + rb.setTranslation({ x: this.targetPosition[0], y: this.targetPosition[1], z: this.targetPosition[2] }, true); + rb.setLinvel({ x: 0, y: 0, z: 0 }, true); + return true; + } + + const scale = step / dist; + rb.setTranslation({ x: px + dx * scale, y: py + dy * scale, z: pz + dz * scale }, true); + rb.setLinvel({ x: 0, y: 0, z: 0 }, true); + return false; + } +} diff --git a/webclient/src/scripts/coroutines.ts b/webclient/src/scripts/coroutines.ts index 217239e..b91bd48 100644 --- a/webclient/src/scripts/coroutines.ts +++ b/webclient/src/scripts/coroutines.ts @@ -1,33 +1,32 @@ -import type { ScriptAction, ScriptCoroutine } from "../types"; +import type { NamedScriptCoroutine, ScriptAction } from "../types"; + +type CoroutineItem = NamedScriptCoroutine & { + currentAction?: ScriptAction; +}; export class ScriptCoroutines { - private items: { coroutine: ScriptCoroutine, currentAction?: ScriptAction }[] = []; + private items: CoroutineItem[] = []; - public push(coroutines: ScriptCoroutine[]) { - this.items.push( - ...coroutines - .map((c) => ({ coroutine: c })), - ); + public push(coroutines: NamedScriptCoroutine[]) { + this.items.push(...coroutines.map((c) => ({ ...c }))); } public tick(deltaTime: number): void { - // execute coroutines one by one and remove the ones that have finished - this.items = this.items - .filter((c) => !this.tickItem(c, deltaTime)); + this.items = this.items.filter((c) => !this.tickItem(c, deltaTime)); } - private tickItem(item: { coroutine: ScriptCoroutine, currentAction?: ScriptAction }, deltaTime: number): boolean { + private tickItem(item: CoroutineItem, deltaTime: number): boolean { if (!item.currentAction) { try { const yieldResult = item.coroutine.next(); if (yieldResult.done) - return true; // coroutine exhausted + return true; item.currentAction = yieldResult.value; } catch (err) { - console.error(err); - return true; // coroutine failed to yield an action or return + console.error(`[${item.name}] Script error:`, err); + return true; } } @@ -37,7 +36,7 @@ export class ScriptCoroutines { item.currentAction = undefined; } catch (err) { - console.error(err); + console.error(`[${item.name}] Action error in ${item.currentAction?.constructor.name}:`, err); item.currentAction = undefined; } diff --git a/webclient/src/scripts/eventBus.ts b/webclient/src/scripts/eventBus.ts index 84e7fc9..0badb88 100644 --- a/webclient/src/scripts/eventBus.ts +++ b/webclient/src/scripts/eventBus.ts @@ -36,15 +36,12 @@ export class ScriptGameEventBus { } emit(event: string, context: Omit): void { - console.log('emitting ' + event); - const coroutines = this.handlers[event] - ?.map((h) => { - console.log(`passing ${event} to ${h.handler}(${JSON.stringify(context)}, ${JSON.stringify(h.coroutine)})`); - return h.handler({ ...context, ...h.coroutine }); - }); - - console.log(coroutines); + ?.map((h) => ({ + owner: h.coroutine.thisObject.id, + name: `${h.coroutine.thisObjectType.name}[${h.coroutine.thisObject.id}]:${event}`, + coroutine: h.handler({ ...context, ...h.coroutine }), + })); if (coroutines) this.coroutines.push(coroutines); diff --git a/webclient/src/scripts/positionOfObject.ts b/webclient/src/scripts/positionOfObject.ts new file mode 100644 index 0000000..8f28510 --- /dev/null +++ b/webclient/src/scripts/positionOfObject.ts @@ -0,0 +1,10 @@ +import type { RuntimeGameObjectInstance, V3 } from "../types"; + +export function positionOfObject(obj: RuntimeGameObjectInstance): V3 { + const t = obj.rb()?.translation(); + const result: V3 = t + ? [t.x, t.y, t.z] + : [obj.position[0], obj.position[1], obj.position[2]]; + + return result; +} diff --git a/webclient/src/state/debugLabelsStore.ts b/webclient/src/state/debugLabelsStore.ts new file mode 100644 index 0000000..8434e9e --- /dev/null +++ b/webclient/src/state/debugLabelsStore.ts @@ -0,0 +1,39 @@ +import { makeAutoObservable } from "mobx"; + +class DebugLabelsStore { + public labels = new Map>(); + + constructor() { + makeAutoObservable(this); + } + + public getAll(objectId: string): Record { + return this.labels.get(objectId) ?? {}; + } + + public getOne(objectId: string, key: string): string | undefined { + return this.getAll(objectId)[key]; + } + + public setOne(objectId: string, key: string, text: string) { + if (!this.labels.has(objectId)) + this.labels.set(objectId, {}); + this.labels.get(objectId)![key] = text; + } + + public deleteAll(objectId: string) { + this.labels.delete(objectId); + } + + public deleteOne(objectId: string, key: string) { + if (this.labels.has(objectId)) { + delete (this.labels.get(objectId)![key]); + } + } + + public clear() { + this.labels.clear(); + } +} + +export const debugLabelsStore = new DebugLabelsStore(); diff --git a/webclient/src/state/gameState.ts b/webclient/src/state/gameState.ts index 3ca368b..70a3a02 100644 --- a/webclient/src/state/gameState.ts +++ b/webclient/src/state/gameState.ts @@ -124,6 +124,10 @@ export class GameState { if (this.isPaused) return; + this.step(deltaTime); + } + + public step(deltaTime: number = 1 / 60): void { this.coroutines.tick(deltaTime); this.time += deltaTime; diff --git a/webclient/src/types/model/runtime.ts b/webclient/src/types/model/runtime.ts index c53b4b6..6895820 100644 --- a/webclient/src/types/model/runtime.ts +++ b/webclient/src/types/model/runtime.ts @@ -4,6 +4,8 @@ import type { ObjectInstance } from "./object"; export type RigidBodyHandle = { applyImpulse(impulse: { x: number; y: number; z: number }, wakeUp: boolean): void; + setLinvel(vel: { x: number; y: number; z: number }, wakeUp: boolean): void; + setTranslation(translation: { x: number; y: number; z: number }, wakeUp: boolean): void; translation(): { x: number; y: number; z: number }; linvel(): { x: number; y: number; z: number }; angvel(): { x: number; y: number; z: number }; diff --git a/webclient/src/types/scripts/eventBus.ts b/webclient/src/types/scripts/eventBus.ts index 62dc99b..e9b93f1 100644 --- a/webclient/src/types/scripts/eventBus.ts +++ b/webclient/src/types/scripts/eventBus.ts @@ -1,7 +1,12 @@ -import type { ObjectType, RuntimeGameObjectInstance, Scene, World } from "../model"; +import type { ObjectInstance, ObjectType, RuntimeGameObjectInstance, Scene, World } from "../model"; import type { ScriptAction } from "./actions"; export type ScriptCoroutine = Generator; +export type NamedScriptCoroutine = { + name: string; + owner: ObjectInstance['id']; + coroutine: ScriptCoroutine; +} export type ScriptCoroutineContext = { thisObject: RuntimeGameObjectInstance, diff --git a/webclient/src/utils/runtime/object.ts b/webclient/src/utils/runtime/object.ts index a1f87a6..fffe241 100644 --- a/webclient/src/utils/runtime/object.ts +++ b/webclient/src/utils/runtime/object.ts @@ -1,5 +1,5 @@ import { getObjectVoxelGroups } from "../graphics/voxelGroup"; -import type { ObjectInstance, ObjectType, RuntimeObjectInstance, V3, World } from "../../types"; +import type { GameObjectInstance, ObjectInstance, ObjectType, RuntimeGameObjectInstance, RuntimeObjectInstance, V3, World } from "../../types"; import { buildObjectTrimesh } from "../graphics/mesh"; export function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 } { @@ -37,3 +37,8 @@ export function depopulateRuntimeObject(object: RuntimeObjectInstance, _world: W const { cache: _cache, rb: _rb, ...result } = object; return result; } + + +export function isGameObject(obj: ObjectInstance): obj is GameObjectInstance { + return 'version' in obj; +}