Compare commits

..

4 Commits

Author SHA1 Message Date
azykov@mail.ru b017beca2d
added and fixed many blocks 2026-06-05 17:29:26 +03:00
azykov@mail.ru 9dbd8d607c
object click event 2026-06-05 15:25:52 +03:00
azykov@mail.ru 3d518ce3f0
camera fix for large player models 2026-06-05 13:16:06 +03:00
azykov@mail.ru fa459ba8ec
replaced characer with any object can be controlled by player 2026-06-05 11:51:30 +03:00
48 changed files with 642 additions and 306 deletions

View File

@ -13,6 +13,7 @@
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1", "@react-three/fiber": "^9.6.1",
"@react-three/rapier": "^2.2.0", "@react-three/rapier": "^2.2.0",
"@tabler/icons-react": "^3.44.0",
"@types/three": "^0.184.1", "@types/three": "^0.184.1",
"blockly": "^12.5.1", "blockly": "^12.5.1",
"install": "^0.13.0", "install": "^0.13.0",

View File

@ -17,6 +17,9 @@ importers:
'@react-three/rapier': '@react-three/rapier':
specifier: ^2.2.0 specifier: ^2.2.0
version: 2.2.0(@react-three/fiber@9.6.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0))(react@19.2.6)(three@0.184.0) version: 2.2.0(@react-three/fiber@9.6.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0))(react@19.2.6)(three@0.184.0)
'@tabler/icons-react':
specifier: ^3.44.0
version: 3.44.0(react@19.2.6)
'@types/three': '@types/three':
specifier: ^0.184.1 specifier: ^0.184.1
version: 0.184.1 version: 0.184.1
@ -537,6 +540,14 @@ packages:
'@rolldown/pluginutils@1.0.1': '@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@tabler/icons-react@3.44.0':
resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==}
peerDependencies:
react: '>= 16'
'@tabler/icons@3.44.0':
resolution: {integrity: sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==}
'@tweenjs/tween.js@23.1.3': '@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
@ -2045,6 +2056,13 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {} '@rolldown/pluginutils@1.0.1': {}
'@tabler/icons-react@3.44.0(react@19.2.6)':
dependencies:
'@tabler/icons': 3.44.0
react: 19.2.6
'@tabler/icons@3.44.0': {}
'@tweenjs/tween.js@23.1.3': {} '@tweenjs/tween.js@23.1.3': {}
'@tybys/wasm-util@0.10.2': '@tybys/wasm-util@0.10.2':

View File

@ -5,6 +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')
.appendField('print'); .appendField('print');
this.setInputsInline(false) this.setInputsInline(false)
this.setPreviousStatement(true, null); this.setPreviousStatement(true, null);

View File

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

View File

@ -0,0 +1,29 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['physics_apply_impulse_action'] = {
init(this: Blockly.Block) {
this.appendDummyInput('')
.appendField('Push me in direction of');
this.appendValueInput('POSITION')
.setCheck('DPos');
this.appendDummyInput('')
.appendField('with force')
.appendField(new Blockly.FieldNumber(1, -10, 10, 0.01), 'FORCE');
this.setInputsInline(true)
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setTooltip('Push me to a position');
this.setColour(150);
}
};
javascriptGenerator.forBlock['physics_apply_impulse_action'] = function (block, generator) {
const positionValue = generator.valueToCode(block, 'POSITION', Order.ATOMIC);
const forceValue = block.getFieldValue('FORCE');
return `api.applyImpulse(${positionValue}, ${forceValue});\n`;
};
export { };

View File

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

View File

@ -0,0 +1,18 @@
import * as Blockly from "blockly";
import { javascriptGenerator } from "blockly/javascript";
Blockly.Blocks['object_clicked_event'] = {
init(this: Blockly.Block) {
this.appendDummyInput()
.appendField('When I\'m clicked');
this.setStyle('event_hat');
this.setNextStatement(true);
this.setTooltip('Fires when an object is clicked');
}
};
javascriptGenerator.forBlock['object_clicked_event'] = function (_block, _generator) {
return '';
};
export { };

View File

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

View File

@ -1,15 +1,13 @@
import * as Blockly from "blockly"; import * as Blockly from "blockly";
import { javascriptGenerator } from "blockly/javascript"; import { javascriptGenerator } from "blockly/javascript";
import { getObjectTypeDropDownOptions } from "../fieldTypes/objectType";
Blockly.Blocks['object_touch_start_event'] = { Blockly.Blocks['object_touch_start_event'] = {
init(this: Blockly.Block) { init(this: Blockly.Block) {
this.appendDummyInput() this.appendDummyInput('NAME')
.appendField('When') .appendField('When');
.appendField( this.appendValueInput('NAME')
new Blockly.FieldDropdown(getObjectTypeDropDownOptions), .setCheck('Object');
'OBJECT_TYPE', this.appendDummyInput('NAME')
)
.appendField('touches me'); .appendField('touches me');
this.setStyle('event_hat'); this.setStyle('event_hat');
this.setNextStatement(true); this.setNextStatement(true);

View File

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

View File

@ -1,11 +1,11 @@
import { state } from "../../state/rootState"; import * as Blockly from "blockly";
import { toJS } from "mobx";
export function getObjectTypeDropDownOptions(): [string, string][] { export class ObjecTypeField extends Blockly.Field {
const types = Object.values(toJS(state.world.data.objectTypes)); constructor(value: any, validator: any) {
const values: [string, string][] = [ super(value, validator);
['Any object', '*'],
...types.map(t => [t.name, t.id] as [string, string]), this.SERIALIZABLE = true;
]; }
return values;
} }
Blockly.fieldRegistry.register('field_object_type', ObjecTypeField);

View File

@ -14,14 +14,23 @@ export function generateCode(workspace: Blockly.WorkspaceSvg): string {
} }
body = javascriptGenerator.prefixLines(body, javascriptGenerator.INDENT); body = javascriptGenerator.prefixLines(body, javascriptGenerator.INDENT);
const args = Array.from(block.getFields()) // const args = Array.from(block.getFields())
.filter(field => field.name !== undefined) // .filter(field => field.name !== undefined)
.map(field => `${field.name}: ${JSON.stringify(field.getValue())}`) // .map(field => `${field.name}: ${JSON.stringify(field.getValue())}`)
.join(', '); // .join(', ');
//TODO use args //TODO use args
result += `onGameEvent('${block.type.replace(/_event$/, '')}', function(context) {\n${body}});\n`; result += `onGameEvent(
'${block.type.replace(/_event$/, '')}',
function(context) {
// console.dir({context, api, object, objectType});
if (context.object && (context.object.id !== object.id))
return;
${body}
}
);
`;
} }
} }
return result; return result;

View File

@ -3,3 +3,4 @@ export * from './values';
export * from './actions'; export * from './actions';
export * from './theme'; export * from './theme';
export * from './generateCode'; export * from './generateCode';
export * from './fieldTypes';

View File

@ -0,0 +1,21 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['any_object_of_type_value'] = {
init(this: Blockly.Block) {
this.appendDummyInput('NAME')
.appendField('Any object of type');
this.appendValueInput('NAME')
.setCheck('ObjectType');
this.setInputsInline(true);
this.setOutput(true, 'Object');
this.setTooltip('Any object of type');
this.setColour(315);
}
};
javascriptGenerator.forBlock['any_object_of_type_value'] = function (_block, _generator) {
return ['123', Order.NONE];
};
export { };

View File

@ -4,16 +4,16 @@ import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['current_object_value'] = { Blockly.Blocks['current_object_value'] = {
init(this: Blockly.Block) { init(this: Blockly.Block) {
this.appendEndRowInput('NAME') this.appendEndRowInput('NAME')
.appendField('Current object'); .appendField('Me');
this.setInputsInline(false) this.setInputsInline(false)
this.setOutput(true, 'String'); this.setOutput(true, 'Object');
this.setTooltip('Returns id of current object instance'); this.setTooltip('Returns current object instance');
this.setColour(315); this.setColour(315);
} }
}; };
javascriptGenerator.forBlock['current_object_value'] = function (_block, _generator) { javascriptGenerator.forBlock['current_object_value'] = function (_block, _generator) {
return ['object.id', Order.NONE]; return ['object', Order.NONE];
}; };
export { }; export { };

View File

@ -4,16 +4,16 @@ import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['current_object_type_value'] = { Blockly.Blocks['current_object_type_value'] = {
init(this: Blockly.Block) { init(this: Blockly.Block) {
this.appendEndRowInput('NAME') this.appendEndRowInput('NAME')
.appendField('Current object type'); .appendField('My type');
this.setInputsInline(false) this.setInputsInline(false)
this.setOutput(true, 'String'); this.setOutput(true, 'ObjectType');
this.setTooltip('Returns id of current object type'); this.setTooltip('Returns current object type');
this.setColour(315); this.setColour(315);
} }
}; };
javascriptGenerator.forBlock['current_object_type_value'] = function (_block, _generator) { javascriptGenerator.forBlock['current_object_type_value'] = function (_block, _generator) {
return ['objectType.id', Order.NONE]; return ['objectType', Order.NONE];
}; };
export { }; export { };

View File

@ -0,0 +1,29 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['dpos_value'] = {
init(this: Blockly.Block) {
this.appendDummyInput()
.appendField('Direction [')
.appendField(new Blockly.FieldNumber(0, -Infinity, Infinity, 0.01), 'X')
.appendField(',')
.appendField(new Blockly.FieldNumber(0, -Infinity, Infinity, 0.01), 'Y')
.appendField(',')
.appendField(new Blockly.FieldNumber(0, -Infinity, Infinity, 0.01), 'Z');
this.appendDummyInput()
.appendField(']');
this.setInputsInline(true);
this.setOutput(true, 'DPos');
this.setTooltip('Direction [dx,dy,dz]');
this.setColour(315);
}
};
javascriptGenerator.forBlock['dpos_value'] = function (block, _generator) {
const xValue = block.getFieldValue('X');
const yValue = block.getFieldValue('Y');
const zValue = block.getFieldValue('Z');
return [`[${xValue},${yValue},${zValue}]`, Order.ATOMIC];
};
export { };

View File

@ -0,0 +1,22 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['direction_to_position_value'] = {
init(this: Blockly.Block) {
this.appendDummyInput()
.appendField('Direction to');
this.appendValueInput('TARGET')
.setCheck('Pos');
this.setInputsInline(true);
this.setOutput(true, 'DPos');
this.setTooltip('Direction to a position');
this.setColour(315);
}
};
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];
};
export { };

View File

@ -1,2 +1,9 @@
export * from './currentObject'; export * from './currentObject';
export * from './currentObjectType'; export * from './currentObjectType';
export * from './anyObjectOfType';
export * from './objectType';
export * from './positionOfObject';
export * from './position';
export * from './direction';
export * from './objectById';
export * from './directionToPosition';

View File

@ -0,0 +1,21 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['object_by_id_value'] = {
init(this: Blockly.Block) {
this.appendEndRowInput()
.appendField('Object with id')
.appendField(new Blockly.FieldTextInput(''), 'TARGET_ID');
this.setInputsInline(false)
this.setOutput(true, 'Object');
this.setTooltip('Returns object by id, if any');
this.setColour(315);
}
};
javascriptGenerator.forBlock['object_by_id_value'] = function (block, _generator) {
const targetIdValue = block.getFieldValue('TARGET_ID');
return [`context.scene.objects['${targetIdValue}']`, Order.ATOMIC];
};
export { };

View File

@ -0,0 +1,33 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
import { toJS } from "mobx";
import { state } from "../../state";
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;
}
Blockly.Blocks['object_type_value'] = {
init(this: Blockly.Block) {
this.appendEndRowInput('NAME')
.appendField(
new Blockly.FieldDropdown(getObjectTypeDropDownOptions),
'OBJECT_TYPE',
);
this.setInputsInline(false);
this.setOutput(true, 'ObjectType');
this.setTooltip('Object type');
this.setColour(315);
}
};
javascriptGenerator.forBlock['current_object_type_value'] = function (_block, _generator) {
return ['objectType', Order.NONE];
};
export { };

View File

@ -0,0 +1,29 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['pos_value'] = {
init(this: Blockly.Block) {
this.appendDummyInput('POSITION')
.appendField('Position [')
.appendField(new Blockly.FieldNumber(0, -Infinity, Infinity, 0.01), 'X')
.appendField(',')
.appendField(new Blockly.FieldNumber(0, -Infinity, Infinity, 0.01), 'Y')
.appendField(',')
.appendField(new Blockly.FieldNumber(0, -Infinity, Infinity, 0.01), 'Z');
this.appendDummyInput()
.appendField(']');
this.setInputsInline(true);
this.setOutput(true, 'Pos');
this.setTooltip('Position [x,y,z]');
this.setColour(315);
}
};
javascriptGenerator.forBlock['pos_value'] = function (block, _generator) {
const xValue = block.getFieldValue('X');
const yValue = block.getFieldValue('Y');
const zValue = block.getFieldValue('Z');
return [`[${xValue},${yValue},${zValue}]`, Order.ATOMIC];
};
export { };

View File

@ -0,0 +1,22 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['position_of_object_value'] = {
init(this: Blockly.Block) {
this.appendDummyInput()
.appendField('Position of');
this.appendValueInput('TARGET')
.setCheck('Object');
this.setInputsInline(true);
this.setOutput(true, 'Pos');
this.setTooltip('Position of an object');
this.setColour(315);
}
};
javascriptGenerator.forBlock['position_of_object_value'] = function (block, generator) {
const targetValue = generator.valueToCode(block, 'TARGET', Order.ATOMIC);
return [`${targetValue}.position`, Order.NONE];
};
export { };

View File

@ -1,22 +1,20 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import type { Character } from "../types"; import type { ObjectType, RuntimeGameObjectInstance } from "../types";
import { SyncRigidBody } from "./SyncRigidBody"; import { ObjectViewInternal, type ObjectViewInternalHandle } from "./ObjectViewInternal";
import { state } from "../state"; import { useEffect, useMemo, useRef } from "react";
import { useEffect, useRef } from "react";
import { Euler, Quaternion, Vector3 } from "three"; import { Euler, Quaternion, Vector3 } from "three";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { useKeyboardControls } from "@react-three/drei"; import { useKeyboardControls } from "@react-three/drei";
import { useRapier, useBeforePhysicsStep, type RapierRigidBody, type RapierCollider, RoundCuboidCollider } from "@react-three/rapier"; import { useRapier, useBeforePhysicsStep } from "@react-three/rapier";
import { joystickValues } from "../joystickInput"; import { joystickValues } from "../joystickInput";
import { state } from "../state";
const SPEED = 5; const SPEED = 5;
const JUMP_SPEED = 8; const JUMP_SPEED = 8;
const GRAVITY = 20; const GRAVITY = 20;
const SENSITIVITY = 0.002; const SENSITIVITY = 0.002;
const SHOULDER_OFFSET = new Vector3(0.1, 1.5, 2.5);
const LOOK_RATE = 2000; const LOOK_RATE = 2000;
// recreate private types
type RapierWorldCreateCharacterControllerFunction = ReturnType<typeof useRapier>['world']['createCharacterController']; type RapierWorldCreateCharacterControllerFunction = ReturnType<typeof useRapier>['world']['createCharacterController'];
type KinematicCharacterController = ReturnType<RapierWorldCreateCharacterControllerFunction>; type KinematicCharacterController = ReturnType<RapierWorldCreateCharacterControllerFunction>;
@ -26,19 +24,30 @@ const _offset = new Vector3();
const _charPos = new Vector3(); const _charPos = new Vector3();
const _lookAt = new Vector3(); const _lookAt = new Vector3();
type CharacterViewProps = { type PlayerObjectViewProps = {
character: Character; object: RuntimeGameObjectInstance;
editMode?: boolean; objectType: ObjectType;
} }
export const CharacterView = observer(function ({ character, editMode }: CharacterViewProps) { export const PlayerObjectView = observer(function ({ object, objectType }: PlayerObjectViewProps) {
const pos = character.transform.position; const handleRef = useRef<ObjectViewInternalHandle>(null);
const rbRef = useRef<RapierRigidBody>(null);
const colliderRef = useRef<RapierCollider>(null);
const [, get] = useKeyboardControls(); const [, get] = useKeyboardControls();
const { gl } = useThree(); const { gl } = useThree();
const { world } = useRapier(); const { world } = useRapier();
const { shoulderOffset, lookAtY } = useMemo(() => {
const bb = object.cache.boundingBox;
const W = bb.max[0] - bb.min[0];
const H = bb.max[1] - bb.min[1];
const D = bb.max[2] - bb.min[2];
const radius = Math.sqrt(W * W + H * H + D * D);
const centerY = (bb.min[1] + bb.max[1]) / 2;
return {
shoulderOffset: new Vector3(W * 0.1, centerY + H * 0.3, bb.max[2] + radius),
lookAtY: centerY,
};
}, [object.id]);
const yawRef = useRef(0); const yawRef = useRef(0);
const pitchRef = useRef(0); const pitchRef = useRef(0);
const mouseRef = useRef({ x: 0, y: 0 }); const mouseRef = useRef({ x: 0, y: 0 });
@ -59,7 +68,6 @@ export const CharacterView = observer(function ({ character, editMode }: Charact
}, [world]); }, [world]);
useEffect(() => { useEffect(() => {
if (editMode) return;
const canvas = gl.domElement; const canvas = gl.domElement;
const onClick = () => canvas.requestPointerLock(); const onClick = () => canvas.requestPointerLock();
const onMouseMove = (e: MouseEvent) => { const onMouseMove = (e: MouseEvent) => {
@ -73,14 +81,15 @@ export const CharacterView = observer(function ({ character, editMode }: Charact
canvas.removeEventListener('click', onClick); canvas.removeEventListener('click', onClick);
document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mousemove', onMouseMove);
}; };
}, [gl, editMode]); }, [gl]);
useBeforePhysicsStep((world) => { useBeforePhysicsStep((world) => {
if (editMode) return; const rb = handleRef.current?.rb;
const rb = rbRef.current; if (!rb) return;
const collider = colliderRef.current; const collider = rb.collider(0);
if (!collider) return;
const controller = controllerRef.current; const controller = controllerRef.current;
if (!rb || !collider || !controller) return; if (!controller) return;
if (state.game?.isPaused) return; if (state.game?.isPaused) return;
const dt = world.timestep; const dt = world.timestep;
@ -118,8 +127,7 @@ export const CharacterView = observer(function ({ character, editMode }: Charact
}); });
useFrame(({ camera }, delta) => { useFrame(({ camera }, delta) => {
if (editMode) return; const rb = handleRef.current?.rb;
const rb = rbRef.current;
if (!rb) return; if (!rb) return;
mouseRef.current.x += joystickValues.look.x * LOOK_RATE * delta; mouseRef.current.x += joystickValues.look.x * LOOK_RATE * delta;
@ -132,30 +140,18 @@ export const CharacterView = observer(function ({ character, editMode }: Charact
const t = rb.translation(); const t = rb.translation();
_charPos.set(t.x, t.y, t.z); _charPos.set(t.x, t.y, t.z);
_offset.copy(SHOULDER_OFFSET).applyEuler(_e.set(pitchRef.current, yawRef.current, 0, 'YXZ')); _offset.copy(shoulderOffset).applyEuler(_e.set(pitchRef.current, yawRef.current, 0, 'YXZ'));
camera.position.copy(_charPos).add(_offset); camera.position.copy(_charPos).add(_offset);
_lookAt.set(t.x, t.y + 1, t.z); _lookAt.set(t.x, t.y + lookAtY, t.z);
camera.lookAt(_lookAt); camera.lookAt(_lookAt);
}); });
return ( return (
<SyncRigidBody <ObjectViewInternal
ref={rbRef} ref={handleRef}
type="kinematicPosition" object={object}
colliders={false} objectType={objectType}
position={[pos[0] + 0.5, pos[1] + 0.5, pos[2] + 0.5]} isPlayer
onSync={() => { }} />
>
{/* <BallCollider ref={colliderRef} args={[0.55]} /> */}
{/* <CapsuleCollider ref={colliderRef} args={[0.4, 0.5]} /> */}
{/* <CuboidCollider ref={colliderRef} args={[0.55, 0.4, 0.55]} /> */}
<RoundCuboidCollider ref={colliderRef} args={[0.4, 0.4, 0.4, 0.1]} />
<group>
<mesh rotation={[-Math.PI / 2, -Math.PI / 4, 0]}>
<coneGeometry args={[0.55, 0.8, 4]} />
<meshStandardMaterial color="yellow" />
</mesh>
</group>
</SyncRigidBody>
); );
}); });

View File

@ -1,17 +1,29 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import type { ObjectInstance, Runtime } from "../types"; import type { RuntimeGameObjectInstance } from "../types";
import { state } from "../state"; import { state } from "../state";
import { ObjectViewInternal } from "./ObjectViewInternal"; import { ObjectViewInternal } from "./ObjectViewInternal";
import { PlayerObjectView } from "./CharacterView";
export type GameObjectViewProps = { export type GameObjectViewProps = {
object: Runtime<ObjectInstance>; object: RuntimeGameObjectInstance;
isPlayer: boolean;
} }
export const GameObjectView = observer(function (props: GameObjectViewProps) { export const GameObjectView = observer(function (props: GameObjectViewProps) {
const objectType = state.world.getObjectTypeById(props.object.typeId); const objectType = state.world.getObjectTypeById(props.object.typeId);
if (!objectType) if (!objectType)
return null; return null;
return <ObjectViewInternal {...props} objectType={objectType} /> if (props.isPlayer)
return <PlayerObjectView object={props.object as RuntimeGameObjectInstance} objectType={objectType} />;
function handleClick(): void {
state.game?.emitEvent('object_clicked', { object: props.object });
}
return <ObjectViewInternal
{...props}
objectType={objectType}
onClick={handleClick}
/>;
}); });

View File

@ -10,7 +10,7 @@
display: none; display: none;
} }
display: block; display: flex;
padding: 8px 8px; padding: 8px 8px;
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 4px;
@ -28,6 +28,17 @@
background: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.15);
color: #fff; color: #fff;
} }
&>.title {
flex: 1;
}
& svg {
vertical-align: middle;
margin: 2px;
box-sizing: border-box;
}
} }
& details { & details {

View File

@ -14,13 +14,30 @@ export const MenuNodeView = observer(function ({ node }: { node: MenuNode }) {
ref.current.open = true; ref.current.open = true;
}, [forceOpen]); }, [forceOpen]);
const classNames = [];
if (node.selected?.())
classNames.push('selected');
if (node.className !== undefined)
classNames.push(node.className);
return ( return (
<details ref={ref}> <details ref={ref}>
<summary <summary
className={node.selected?.() ? 'selected' : ''} className={classNames.join(' ')}
onClick={() => node.onClick?.()} onClick={() => node.onClick?.()}
> >
{node.title} <div className="title">{node.title}</div>
{node.actions && <div className="actions">
{node.actions.map((action) =>
<div
key={action.id}
title={action.tooltip}
onClick={(e) => { e.stopPropagation(); e.preventDefault(); action.onClick(); }}
>
{action.content}
</div>
)}
</div>}
</summary> </summary>
{node.children?.map((child) => <MenuNodeView key={child.id} node={child} />)} {node.children?.map((child) => <MenuNodeView key={child.id} node={child} />)}
</details> </details>

View File

@ -11,6 +11,7 @@ import { ObjectViewInternal, type ObjectViewInternalHandle } from "./ObjectViewI
type ObjectEditorViewProps = { type ObjectEditorViewProps = {
object: Runtime<ObjectInstance>; object: Runtime<ObjectInstance>;
isPlayer: boolean;
} }
type SelectionOverlayProps = { type SelectionOverlayProps = {
@ -50,7 +51,7 @@ const SelectionOverlay = observer(function ({ objectId, ref, onTransformEnd }: S
); );
}); });
export const ObjectEditorView = observer(function ({ object }: ObjectEditorViewProps) { export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectEditorViewProps) {
const dataRef = useRef<ObjectViewInternalHandle>(null); const dataRef = useRef<ObjectViewInternalHandle>(null);
// Only observes world object types — not selection state — so won't // Only observes world object types — not selection state — so won't
@ -59,15 +60,14 @@ export const ObjectEditorView = observer(function ({ object }: ObjectEditorViewP
if (!objectType) if (!objectType)
return null; return null;
function handleClick(e: ThreeEvent<MouseEvent>) { function handleClick() {
if (e.delta > 5) return; if (state.worldEditor.isEnabled) {
e.stopPropagation(); // Reading selection state inside an event handler: not tracked by observer.
// Reading selection state inside an event handler: not tracked by observer. const currentMode = state.worldEditor.selection?.type === 'object' && state.worldEditor.selection?.id === object.id
const currentMode = state.worldEditor.isEnabled && ? state.worldEditor.selection?.editMode
state.worldEditor.selection?.type === 'object' && state.worldEditor.selection?.id === object.id : undefined;
? state.worldEditor.selection?.editMode state.worldEditor.setSelectedObject({ id: object.id, editMode: nextObjectEditMode(currentMode) });
: undefined; }
state.worldEditor.setSelectedObject({ id: object.id, editMode: nextObjectEditMode(currentMode) });
} }
function handleTransformEnd() { function handleTransformEnd() {
@ -91,6 +91,7 @@ export const ObjectEditorView = observer(function ({ object }: ObjectEditorViewP
ref={dataRef} ref={dataRef}
object={object} object={object}
isEditor isEditor
isPlayer={isPlayer}
objectType={objectType} objectType={objectType}
onClick={handleClick} onClick={handleClick}
/> />

View File

@ -17,7 +17,8 @@ type ObjectViewInternalProps = {
object: Omit<RuntimeObjectInstance, 'typeId'>; object: Omit<RuntimeObjectInstance, 'typeId'>;
objectType: ObjectType; objectType: ObjectType;
isEditor?: boolean; isEditor?: boolean;
onClick?: (e: ThreeEvent<MouseEvent>) => void; isPlayer?: boolean;
onClick?: () => void;
ref?: Ref<ObjectViewInternalHandle>; ref?: Ref<ObjectViewInternalHandle>;
} }
@ -28,38 +29,70 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
useImperativeHandle(ref, () => ({ group: groupRef.current, rb: rbRef.current })); useImperativeHandle(ref, () => ({ group: groupRef.current, rb: rbRef.current }));
useEffect( useEffect(
() => reaction( () => {
() => 'version' in object ? object.version : 0, if (props.isPlayer) return;
() => { return reaction(
if (!rbRef.current) () => 'version' in object ? object.version : 0,
return; () => {
if (!rbRef.current)
return;
const gameObj = object as RuntimeGameObjectInstance; const gameObj = object as RuntimeGameObjectInstance;
// position rbRef.current.setTranslation(v3toRapier(gameObj.position), true);
rbRef.current.setTranslation(v3toRapier(gameObj.position), true);
// rotation const euler = new Euler(gameObj.rotation[0], gameObj.rotation[1], gameObj.rotation[2]);
const euler = new Euler(gameObj.rotation[0], gameObj.rotation[1], gameObj.rotation[2]); const q = new Quaternion().setFromEuler(euler);
const q = new Quaternion().setFromEuler(euler); rbRef.current.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, true);
rbRef.current.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, true);
// scale groupRef.current?.scale.set(...gameObj.scale);
groupRef.current?.scale.set(...gameObj.scale);
rbRef.current.setLinvel(v3toRapier(gameObj.linearVelocity), true); rbRef.current.setLinvel(v3toRapier(gameObj.linearVelocity), true);
rbRef.current.setAngvel(v3toRapier(gameObj.angularVelocity), true); rbRef.current.setAngvel(v3toRapier(gameObj.angularVelocity), true);
}, },
), );
},
[object.id] [object.id]
); );
useEffect(
() => {
const gameObj = object as RuntimeGameObjectInstance;
return reaction(
() => gameObj.pendingActions.impulse,
(impulse) => {
if (!impulse) return;
if (!rbRef.current) { console.warn('applyImpulse: rbRef is null for', gameObj.id); return; }
let { direction, amplitude } = impulse;
amplitude *= 100;
const v = { x: direction[0] * amplitude, y: direction[1] * amplitude, z: direction[2] * amplitude };
console.log('applyImpulse', gameObj.id, v, 'bodyType', rbRef.current.bodyType());
rbRef.current.applyImpulse(v, true);
runInAction(() => {
gameObj.pendingActions.impulse = undefined;
});
},
);
},
[object.id]
);
function handleClick(e: ThreeEvent<MouseEvent>) {
if (e.delta > 5)
return;
e.stopPropagation();
props.onClick?.();
}
return ( return (
<SyncRigidBody <SyncRigidBody
ref={rbRef} ref={rbRef}
colliders={false} colliders={false}
type={object.physics ? 'dynamic' : 'fixed'} type={props.isPlayer ? 'kinematicPosition' : (object.physics ? 'dynamic' : 'fixed')}
gravityScale={object.gravityScale} gravityScale={object.gravityScale}
position={object.position} position={object.position}
rotation={object.rotation} rotation={object.rotation}
@ -79,7 +112,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
ref={groupRef} ref={groupRef}
name={`${object.id} (${objectType.id} instance)`} name={`${object.id} (${objectType.id} instance)`}
scale={object.scale} scale={object.scale}
onClick={props.onClick} onClick={handleClick}
> >
{ {
object.cache.voxelGroups.map((vg) => object.cache.voxelGroups.map((vg) =>

View File

@ -1,6 +1,5 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import type { RuntimeScene } from "../types"; import type { RuntimeScene } from "../types";
import { CharacterView } from "./CharacterView";
import { GameObjectView } from "./GameObjectView"; import { GameObjectView } from "./GameObjectView";
import { ObjectEditorView } from "./ObjectEditorView"; import { ObjectEditorView } from "./ObjectEditorView";
@ -10,30 +9,15 @@ type SceneViewProps = {
} }
export const SceneView = observer(function (props: SceneViewProps) { export const SceneView = observer(function (props: SceneViewProps) {
// const rapier = useRapier();
// useFrame((_, dt) => {
// if (props.editMode)
// return;
// const game = state.game;
// if (!game || game.isPaused)
// return;
// rapier.step(dt);
// })
return (<> return (<>
<ambientLight intensity={0.5} /> <ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1} /> <directionalLight position={[5, 5, 5]} intensity={1} />
{ {
Object.values(props.scene.objects).map((obj) => ( Object.values(props.scene.objects).map((obj) => (
props.editMode props.editMode
? <ObjectEditorView key={obj.id} object={obj} /> ? <ObjectEditorView key={obj.id} object={obj} isPlayer={props.scene.playerObjectId === obj.id} />
: <GameObjectView key={obj.id} object={obj} /> : <GameObjectView key={obj.id} object={obj} isPlayer={props.scene.playerObjectId === obj.id} />
)) ))
} }
{/* {props.editMode && <CharacterView character={props.scene.character} />} */}
{<CharacterView character={props.scene.character} editMode={props.editMode} />}
</>); </>);
}); });

View File

@ -2,6 +2,13 @@ import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { KeyboardControls, Stats } from '@react-three/drei'; import { KeyboardControls, Stats } from '@react-three/drei';
import { action } from 'mobx'; import { action } from 'mobx';
import { chartRef } from './chartRef'; import { chartRef } from './chartRef';
import { observer } from 'mobx-react-lite';
import { state } from '../state';
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';
function RenderInfoUpdater() { function RenderInfoUpdater() {
const { gl } = useThree(); const { gl } = useThree();
@ -20,19 +27,8 @@ function RenderInfoUpdater() {
return null; return null;
} }
import { observer } from 'mobx-react-lite';
import { state } from '../state';
import { GameView } from './GameView';
import { SceneEditorView } from './SceneEditorView';
import { JoystickView } from './JoystickView';
import type { RefObject } from 'react';
const IconStop = () => <svg viewBox="0 0 14 14"><rect x="2" y="2" width="10" height="10" fill="currentColor" /></svg>;
const IconPause = () => <svg viewBox="0 0 14 14"><rect x="2" y="2" width="4" height="10" fill="currentColor" /><rect x="8" y="2" width="4" height="10" fill="currentColor" /></svg >;
const IconPlay = () => <svg viewBox="0 0 14 14"><polygon points="3,1 13,7 3,13" fill="currentColor" /></svg>;
export const ThreeView = observer(function () { export const ThreeView = observer(function () {
const isGame = state.isGamePlaying; const isGame = !!state.game;
return ( return (
<KeyboardControls map={[ <KeyboardControls map={[
{ name: 'forward', keys: ['ArrowUp', 'w', 'W'] }, { name: 'forward', keys: ['ArrowUp', 'w', 'W'] },
@ -51,18 +47,18 @@ export const ThreeView = observer(function () {
{isGame ? <GameView /> : <SceneEditorView />} {isGame ? <GameView /> : <SceneEditorView />}
</Canvas> </Canvas>
{isGame && <JoystickView />} {isGame && <JoystickView />}
<div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4 }}> <div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4, fontSize: 14 }}>
{ {
state.game state.game
? <> ? <>
<button onClick={() => state.stopGame()}><IconStop /></button> <button onClick={() => state.stopGame()}><IconPlayerStopFilled size="1em" /></button>
{ {
state.game!.isPaused state.game!.isPaused
? <button onClick={() => state.game!.resume()}><IconPlay /></button> ? <button onClick={() => state.game!.resume()}><IconPlayerPlayFilled size="1em" /></button>
: <button onClick={() => state.game!.pause()}><IconPause /></button> : <button onClick={() => state.game!.pause()}><IconPlayerPauseFilled size="1em" /></button>
} }
</> </>
: <button onClick={() => state.startGame()}><IconPlay /></button> : <button onClick={() => state.startGame()}><IconPlayerPlayFilled size="1em" /></button>
} }
</div> </div>
</div> </div>

View File

@ -19,6 +19,7 @@ const TOOLBOX: Blockly.utils.toolbox.ToolboxDefinition = {
{ kind: 'block', type: 'game_start_event' }, { kind: 'block', type: 'game_start_event' },
{ kind: 'block', type: 'object_touch_start_event' }, { kind: 'block', type: 'object_touch_start_event' },
{ kind: 'block', type: 'player_touch_start_event' }, { kind: 'block', type: 'player_touch_start_event' },
{ kind: 'block', type: 'object_clicked_event' },
], ],
}, },
{ {
@ -28,6 +29,13 @@ const TOOLBOX: Blockly.utils.toolbox.ToolboxDefinition = {
contents: [ contents: [
{ kind: 'block', type: 'current_object_value' }, { kind: 'block', type: 'current_object_value' },
{ kind: 'block', type: 'current_object_type_value' }, { kind: 'block', type: 'current_object_type_value' },
{ kind: 'block', type: 'object_type_value' },
{ kind: 'block', type: 'any_object_of_type_value' },
{ kind: 'block', type: 'position_of_object_value' },
{ kind: 'block', type: 'pos_value' },
{ kind: 'block', type: 'dpos_value' },
{ kind: 'block', type: 'direction_to_position_value' },
{ kind: 'block', type: 'object_by_id_value' },
], ],
}, },
{ {
@ -37,6 +45,7 @@ const TOOLBOX: Blockly.utils.toolbox.ToolboxDefinition = {
contents: [ contents: [
{ kind: 'block', type: 'console_log_action' }, { kind: 'block', type: 'console_log_action' },
{ kind: 'block', type: 'text_print' }, { kind: 'block', type: 'text_print' },
{ kind: 'block', type: 'physics_apply_impulse_action' },
], ],
}, },
{ {

View File

@ -79,13 +79,13 @@ export class GameFactory {
Object.values(world.objectTypes) Object.values(world.objectTypes)
.filter((ot) => ot.javascript) .filter((ot) => ot.javascript)
.forEach((ot) => { .forEach((ot) => {
const otCode = eval(`(function gameScript({onGameEvent, offGameEvent }, {api, object, objectType}) {${ot.javascript}})`); const gameScript = eval(`(function gameScript({onGameEvent, offGameEvent }, {api, object, objectType}) {${ot.javascript}})`);
const api = new ObjectApi(o, ot, world, scene);
Object.values(scene.objects) Object.values(scene.objects)
.filter((o) => o.typeId == ot.id) .filter((o) => o.typeId == ot.id)
.forEach((o) => { .forEach((o) => {
otCode(internalBus, { api, o, ot }); const api = new ObjectApi(o, ot, world, scene);
gameScript(internalBus, { api, object: o, objectType: ot });
}) })
}); });
} }

View File

@ -11,25 +11,26 @@ function v(x: number, y: number, z: number, color: string): Voxel {
return { typeId: 'stone', position: [x, y, z], color }; return { typeId: 'stone', position: [x, y, z], color };
} }
// Wolf faces +Z (nose at Z=3). Head spans X:-3..3, Y:0..5, Z:-3..3. // Wolf faces -Z (nose at Z=-3). Head spans X:-3..3, Y:0..5, Z:-3..3.
export const wolf: Voxel[] = [ export const wolf: Voxel[] = [
// ── Back of skull Z=-3 ────────────────────────────────────────────── // ── Snout tip + nose Z=-3 ────────────────────────────────────────────
v(-1,2,-3,G), v(0,2,-3,G), v(1,2,-3,G), v(-1,1,-3,L), v(0,1,-3,L), v(1,1,-3,L),
v(-1,3,-3,G), v(0,3,-3,G), v(1,3,-3,G), v(-1,0,-3,L), v(0,0,-3,L), v(1,0,-3,L),
v(-1,4,-3,G), v(0,4,-3,G), v(1,4,-3,G), v(-1,2,-3,N), v(0,2,-3,N), v(1,2,-3,N), // nose
// ── Skull Z=-2 ────────────────────────────────────────────────────── // ── Front face + pupils + snout Z=-2 ────────────────────────────────
v(-2,2,-2,G), v(-1,2,-2,G), v(0,2,-2,G), v(1,2,-2,G), v(2,2,-2,G), v(-2,2,-2,L), v(-1,2,-2,L), v(0,2,-2,L), v(1,2,-2,L), v(2,2,-2,L),
v(-2,3,-2,G), v(-1,3,-2,G), v(0,3,-2,G), v(1,3,-2,G), v(2,3,-2,G), v(-2,3,-2,G), v(-1,3,-2,N), v(0,3,-2,G), v(1,3,-2,N), v(2,3,-2,G),
v(-2,4,-2,G), v(-1,4,-2,G), v(0,4,-2,G), v(1,4,-2,G), v(2,4,-2,G), v(-2,4,-2,G), v(-1,4,-2,G), v(0,4,-2,G), v(1,4,-2,G), v(2,4,-2,G),
v(-1,5,-2,G), v(0,5,-2,G), v(1,5,-2,G), v(-1,0,-2,L), v(0,0,-2,L), v(1,0,-2,L),
v(-1,1,-2,L), v(0,1,-2,L), v(1,1,-2,L),
// ── Skull + ear bases Z=-1 ─────────────────────────────────────────── // ── Face + amber eyes + snout Z=-1 ──────────────────────────────────
v(-2,2,-1,G), v(-1,2,-1,G), v(0,2,-1,G), v(1,2,-1,G), v(2,2,-1,G), v(-2,2,-1,L), v(-1,2,-1,L), v(0,2,-1,L), v(1,2,-1,L), v(2,2,-1,L),
v(-2,3,-1,G), v(-1,3,-1,G), v(0,3,-1,G), v(1,3,-1,G), v(2,3,-1,G), v(-2,3,-1,G), v(-1,3,-1,E), v(0,3,-1,G), v(1,3,-1,E), v(2,3,-1,G),
v(-3,4,-1,G), v(-2,4,-1,G), v(-1,4,-1,G), v(0,4,-1,G), v(1,4,-1,G), v(2,4,-1,G), v(3,4,-1,G), v(-2,4,-1,G), v(-1,4,-1,G), v(0,4,-1,G), v(1,4,-1,G), v(2,4,-1,G),
v(-2,5,-1,G), v(-1,5,-1,G), v(0,5,-1,G), v(1,5,-1,G), v(2,5,-1,G), v(-1,0,-1,L), v(0,0,-1,L), v(1,0,-1,L),
v(-3,5,-1,D), v(3,5,-1,D), v(-1,1,-1,L), v(0,1,-1,L), v(1,1,-1,L),
// ── Face + ears Z=0 ────────────────────────────────────────────────── // ── Face + ears Z=0 ──────────────────────────────────────────────────
v(-2,2,0,L), v(-1,2,0,L), v(0,2,0,L), v(1,2,0,L), v(2,2,0,L), v(-2,2,0,L), v(-1,2,0,L), v(0,2,0,L), v(1,2,0,L), v(2,2,0,L),
@ -41,22 +42,21 @@ export const wolf: Voxel[] = [
v(-1,0,0,L), v(0,0,0,L), v(1,0,0,L), v(-1,0,0,L), v(0,0,0,L), v(1,0,0,L),
v(-1,1,0,L), v(0,1,0,L), v(1,1,0,L), v(-1,1,0,L), v(0,1,0,L), v(1,1,0,L),
// ── Face + amber eyes + snout Z=1 ──────────────────────────────────── // ── Skull + ear bases Z=1 ────────────────────────────────────────────
v(-2,2,1,L), v(-1,2,1,L), v(0,2,1,L), v(1,2,1,L), v(2,2,1,L), v(-2,2,1,G), v(-1,2,1,G), v(0,2,1,G), v(1,2,1,G), v(2,2,1,G),
v(-2,3,1,G), v(-1,3,1,E), v(0,3,1,G), v(1,3,1,E), v(2,3,1,G), v(-2,3,1,G), v(-1,3,1,G), v(0,3,1,G), v(1,3,1,G), v(2,3,1,G),
v(-2,4,1,G), v(-1,4,1,G), v(0,4,1,G), v(1,4,1,G), v(2,4,1,G), v(-3,4,1,G), v(-2,4,1,G), v(-1,4,1,G), v(0,4,1,G), v(1,4,1,G), v(2,4,1,G), v(3,4,1,G),
v(-1,0,1,L), v(0,0,1,L), v(1,0,1,L), v(-2,5,1,G), v(-1,5,1,G), v(0,5,1,G), v(1,5,1,G), v(2,5,1,G),
v(-1,1,1,L), v(0,1,1,L), v(1,1,1,L), v(-3,5,1,D), v(3,5,1,D),
// ── Front face + pupils + snout Z=2 ────────────────────────────────── // ── Skull Z=2 ───────────────────────────────────────────────────────
v(-2,2,2,L), v(-1,2,2,L), v(0,2,2,L), v(1,2,2,L), v(2,2,2,L), v(-2,2,2,G), v(-1,2,2,G), v(0,2,2,G), v(1,2,2,G), v(2,2,2,G),
v(-2,3,2,G), v(-1,3,2,N), v(0,3,2,G), v(1,3,2,N), v(2,3,2,G), v(-2,3,2,G), v(-1,3,2,G), v(0,3,2,G), v(1,3,2,G), v(2,3,2,G),
v(-2,4,2,G), v(-1,4,2,G), v(0,4,2,G), v(1,4,2,G), v(2,4,2,G), v(-2,4,2,G), v(-1,4,2,G), v(0,4,2,G), v(1,4,2,G), v(2,4,2,G),
v(-1,0,2,L), v(0,0,2,L), v(1,0,2,L), v(-1,5,2,G), v(0,5,2,G), v(1,5,2,G),
v(-1,1,2,L), v(0,1,2,L), v(1,1,2,L),
// ── Snout tip + nose Z=3 ───────────────────────────────────────────── // ── Back of skull Z=3 ───────────────────────────────────────────────
v(-1,1,3,L), v(0,1,3,L), v(1,1,3,L), v(-1,2,3,G), v(0,2,3,G), v(1,2,3,G),
v(-1,0,3,L), v(0,0,3,L), v(1,0,3,L), v(-1,3,3,G), v(0,3,3,G), v(1,3,3,G),
v(-1,2,3,N), v(0,2,3,N), v(1,2,3,N), // nose v(-1,4,3,G), v(0,4,3,G), v(1,4,3,G),
]; ];

View File

@ -9,12 +9,6 @@ export class WorldFactory {
objectTypes: {}, objectTypes: {},
voxelTypes: DEFAULT_VOXEL_TYPES, voxelTypes: DEFAULT_VOXEL_TYPES,
initialScene: { initialScene: {
character: {
transform: {
position: [0, 0, 0],
look: [0, 0, 0],
},
},
objects: {}, objects: {},
}, },
gameRules: { gameRules: {

View File

@ -1,8 +1,9 @@
import { makeAutoObservable, reaction, toJS } from "mobx"; 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, 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 type { GameEventContext } from "../types/runtime/gameBus";
export class GameState { export class GameState {
private readonly world: WorldState; private readonly world: WorldState;
@ -45,18 +46,18 @@ export class GameState {
const eventBus = new GameEventBus(); const eventBus = new GameEventBus();
this.eventBus = eventBus; this.eventBus = eventBus;
GameFactory.evalGameScripts(rawWorld, game.scene, eventBus);
this._stopAutoSave = this.startAutoSave(); this._stopAutoSave = this.startAutoSave();
makeAutoObservable( makeAutoObservable(this);
this,
); GameFactory.evalGameScripts(rawWorld, this.scene, eventBus);
} }
public emitEvent(event: string) { public emitEvent(event: string, context: Omit<GameEventContext, 'world' | 'scene'> = {}) {
this.eventBus.emit( this.eventBus.emit(
event, event,
{ {
...context,
world: this.world.data, world: this.world.data,
scene: this.scene, scene: this.scene,
}, },
@ -83,7 +84,6 @@ export class GameState {
public get camera(): Pos3 { public get camera(): Pos3 {
return this.world.data.editorCamera; return this.world.data.editorCamera;
return this.scene.character.transform;
} }
public get cameraAsThree(): CameraProps { public get cameraAsThree(): CameraProps {
@ -122,23 +122,6 @@ export class GameState {
this.isPaused = true; this.isPaused = true;
} }
public setCharacterTransform(
transform: Pos3,
linearVelocity?: V3,
angularVelocity?: R3,
): void {
if (this.isPaused)
return;
this.scene.character.transform = transform;
if (linearVelocity)
this.scene.character.linearVelocity = linearVelocity;
if (angularVelocity)
this.scene.character.angularVelocity = angularVelocity;
// console.log(`changed character to ${JSON.stringify(this.scene.character)}`);
}
public tick(deltaTime: number): void { public tick(deltaTime: number): void {
if (this.isPaused) if (this.isPaused)
return; return;

View File

@ -1,57 +0,0 @@
import { makeAutoObservable } from "mobx";
import { state } from "./rootState";
export type MenuNode = {
id: string;
title: string;
onClick?: () => void;
selected?: () => boolean;
children?: MenuNode[];
}
export class MenuState {
constructor() {
makeAutoObservable(this);
}
private get editorObjectTypesMenu(): MenuNode[] {
return Object.values(state.world.data.objectTypes)
.map((ot) => ({
id: `ot-${ot.id}`,
title: ot.name,
onClick: () => { state.worldEditor.setSelectedObjectType({ id: ot.id, editMode: 'scripts' }) },
selected: () => state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection?.id === ot.id,
children: Object.values(state.worldEditor.scene.objects)
.filter((o) => o.typeId === ot.id)
.map((o) => ({
id: `o-${o.id}`,
title: o.id,
onClick: () => { state.worldEditor.setSelectedObject({ id: o.id, editMode: 'translate' }) },
selected: () => state.worldEditor.selection?.type === 'object' && state.worldEditor.selection?.id === o.id,
}))
}));
}
private get editorMenu(): MenuNode[] {
return [
{
id: 'editor-objects-menu',
title: 'Objects',
onClick: () => { state.worldEditor.resetSelection() },
selected: () => !state.worldEditor.selection,
children: this.editorObjectTypesMenu,
}
]
}
public get nodes(): MenuNode[] {
return [
...this.editorMenu,
]
}
public nodeContainsSelected(node: MenuNode): boolean {
return !!(node.selected?.() || node.children?.some((child) => this.nodeContainsSelected(child)));
}
}

88
src/state/menuState.tsx Normal file
View File

@ -0,0 +1,88 @@
import { makeAutoObservable } from "mobx";
import { state } from "./rootState";
import type { ReactNode } from "react";
import { IconRun } from '@tabler/icons-react';
export type MenuNodeAction = {
id: string;
content: ReactNode;
tooltip: string;
onClick: () => void;
}
export type MenuNode = {
id: string;
title: ReactNode;
className?: string;
actions?: MenuNodeAction[];
onClick?: () => void;
selected?: () => boolean;
children?: MenuNode[];
}
export class MenuState {
constructor() {
makeAutoObservable(this);
}
private get editorObjectTypesMenu(): MenuNode[] {
const scene = state.worldEditor.scene;
return Object.values(state.world.data.objectTypes)
.map((ot) => ({
id: `ot-${ot.id}`,
title: ot.name,
onClick: () => { state.worldEditor.setSelectedObjectType({ id: ot.id, editMode: 'scripts' }) },
selected: () => state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection?.id === ot.id,
children: Object.values(scene.objects)
.filter((o) => o.typeId === ot.id)
.map((o) => {
const isPlayer = scene.playerObjectId === o.id;
const actions: MenuNodeAction[] = [];
if (!isPlayer)
actions.push({
id: 'control-by-player',
content: <IconRun size="1em" />,
tooltip: 'Mark as player',
onClick: () => { state.markObjectAsPlayer(o); },
});
return {
id: `o-${o.id}`,
title: <>
{isPlayer && <IconRun size="1em" />}
{o.id}
</>,
className: isPlayer ? 'player-controlled-object' : undefined,
actions,
onClick: () => { state.worldEditor.setSelectedObject({ id: o.id, editMode: 'translate' }) },
selected: () => state.worldEditor.selection?.type === 'object' && state.worldEditor.selection?.id === o.id,
} as MenuNode;
})
} as MenuNode));
}
private get editorMenu(): MenuNode[] {
return [
{
id: 'editor-objects-menu',
title: 'Objects',
onClick: () => { state.worldEditor.resetSelection() },
selected: () => !state.worldEditor.selection,
children: this.editorObjectTypesMenu,
}
]
}
public get nodes(): MenuNode[] {
return [
...this.editorMenu,
]
}
public nodeContainsSelected(node: MenuNode): boolean {
return !!(node.selected?.() || node.children?.some((child) => this.nodeContainsSelected(child)));
}
}

View File

@ -3,6 +3,7 @@ import { WorldState } from "./worldState";
import { WorldEditorState } from "./worldEditorState"; import { WorldEditorState } from "./worldEditorState";
import { GameState } from "./gameState"; import { GameState } from "./gameState";
import { MenuState } from "./menuState"; import { MenuState } from "./menuState";
import type { RuntimeObjectInstance } from "../types";
export type RenderInfo = { export type RenderInfo = {
calls: number, calls: number,
@ -31,10 +32,6 @@ export class RootState {
); );
} }
public get isGamePlaying(): boolean {
return this.game !== undefined;
}
public startGame(): void { public startGame(): void {
state.worldEditor.resetSelection(); state.worldEditor.resetSelection();
if (this.game) if (this.game)
@ -53,6 +50,13 @@ export class RootState {
public setRenderInfo(value: RenderInfo | undefined) { public setRenderInfo(value: RenderInfo | undefined) {
this.renderInfo = value; this.renderInfo = value;
} }
public markObjectAsPlayer(object: RuntimeObjectInstance) {
if (this.game)
this.game.scene.playerObjectId = object.id;
else
this.world.data.initialScene.playerObjectId = object.id;
}
} }
export const state = new RootState(); export const state = new RootState();

View File

@ -61,7 +61,7 @@ export class WorldEditorState {
} }
public get isEnabled(): boolean { public get isEnabled(): boolean {
return !state.isGamePlaying; return !state.game;
} }
public setCamera(value: Pos3): void { public setCamera(value: Pos3): void {

View File

@ -68,16 +68,10 @@ export class WorldState {
}, },
voxelTypes: DEFAULT_VOXEL_TYPES, voxelTypes: DEFAULT_VOXEL_TYPES,
editorCamera: { editorCamera: {
position: [-9, 11, 30], position: [14, 17, -25],
look: [-0.52, -0.35, -0.2], look: [-2.5, 0.31, 3],
}, },
initialScene: { initialScene: {
character: {
transform: {
position: [0, 5, 20],
look: [0, 0, 0],
},
},
objects: { objects: {
terrain: { terrain: {
id: 'terrain', id: 'terrain',
@ -90,6 +84,7 @@ export class WorldState {
}, },
...objectMap, ...objectMap,
}, },
playerObjectId: 'obj1',
}, },
gameRules: { gameRules: {
gravity: true, gravity: true,

View File

@ -1,8 +0,0 @@
import type { Pos3 } from "../3d";
import type { GameObjectData } from "./object";
export type Character = {
transform: Pos3,
}
export type GameCharacter = Character & GameObjectData;

View File

@ -3,6 +3,5 @@ export * from './scene';
export * from './world'; export * from './world';
export * from './gameRules'; export * from './gameRules';
export * from './game'; export * from './game';
export * from './character';
export * from './voxel'; export * from './voxel';
export * from './runtime'; export * from './runtime';

View File

@ -6,9 +6,10 @@ export type ObjectInstanceRuntimeData = {
cache: { cache: {
voxelGroups: VoxelGroup[]; voxelGroups: VoxelGroup[];
colliderMesh: [Float32Array, Uint32Array] | null; colliderMesh: [Float32Array, Uint32Array] | null;
boundingBox: { min: V3; max: V3 };
}; };
pendingActions: { pendingActions: {
impulse: { direction: V3, amplitude: number }; impulse?: { direction: V3, amplitude: number };
} }
} }

View File

@ -1,22 +1,18 @@
import type { Character, GameCharacter } from "./character";
import type { GameObjectInstance, ObjectInstance, RuntimeGameObjectInstance, RuntimeObjectInstance } from "./object"; import type { GameObjectInstance, ObjectInstance, RuntimeGameObjectInstance, RuntimeObjectInstance } from "./object";
export type Scene = { export type Scene = {
character: Character;
objects: Record<string, ObjectInstance>; objects: Record<string, ObjectInstance>;
playerObjectId: string | undefined;
} }
export type RuntimeScene = { export type RuntimeScene = Scene & {
character: Character;
objects: Record<string, RuntimeObjectInstance>; objects: Record<string, RuntimeObjectInstance>;
} }
export type GameScene = { export type GameScene = Scene & {
character: GameCharacter;
objects: Record<string, GameObjectInstance>; objects: Record<string, GameObjectInstance>;
} }
export type RuntimeGameScene = { export type RuntimeGameScene = Scene & {
character: GameCharacter;
objects: Record<string, RuntimeGameObjectInstance>; objects: Record<string, RuntimeGameObjectInstance>;
} }

View File

@ -1,17 +1,12 @@
import type { ObjectType, RuntimeGameObjectInstance, Scene, World } from "../model"; import type { RuntimeGameObjectInstance, Scene, World } from "../model";
export type GameEventContext = { export type GameEventContext = {
world: World; world: World;
scene: Scene; scene: Scene;
object?: RuntimeGameObjectInstance;
} }
// export type GameObjectEventContext = GameEventContext & {
// object: RuntimeGameObjectInstance;
// objectType: ObjectType;
// }
export type GameEventHandler = (context: GameEventContext) => void; export type GameEventHandler = (context: GameEventContext) => void;
// export type GameObjectEventHandler = (context: GameObjectEventContext) => void;
export type InternalGameEventBus = { export type InternalGameEventBus = {
onGameEvent: (event: string, handler: GameEventHandler) => void; onGameEvent: (event: string, handler: GameEventHandler) => void;

View File

@ -1,7 +1,23 @@
import { getObjectVoxelGroups } from "../graphics/voxelGroup"; import { getObjectVoxelGroups } from "../graphics/voxelGroup";
import type { ObjectInstance, RuntimeObjectInstance, World } from "../../types"; import type { ObjectInstance, ObjectType, RuntimeObjectInstance, V3, World } from "../../types";
import { buildObjectTrimesh } from "../graphics/mesh"; import { buildObjectTrimesh } from "../graphics/mesh";
function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 } {
if (!objectType.voxels.length)
return { min: [0, 0, 0], max: [1, 1, 1] };
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (const v of objectType.voxels) {
minX = Math.min(minX, v.position[0]);
minY = Math.min(minY, v.position[1]);
minZ = Math.min(minZ, v.position[2]);
maxX = Math.max(maxX, v.position[0] + 1);
maxY = Math.max(maxY, v.position[1] + 1);
maxZ = Math.max(maxZ, v.position[2] + 1);
}
return { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] };
}
export function populateRuntimeObject(object: ObjectInstance, world: World): RuntimeObjectInstance { export function populateRuntimeObject(object: ObjectInstance, world: World): RuntimeObjectInstance {
const objectType = world.objectTypes[object.typeId]; const objectType = world.objectTypes[object.typeId];
@ -10,9 +26,10 @@ export function populateRuntimeObject(object: ObjectInstance, world: World): Run
cache: { cache: {
voxelGroups: getObjectVoxelGroups(objectType, world.voxelTypes), voxelGroups: getObjectVoxelGroups(objectType, world.voxelTypes),
colliderMesh: buildObjectTrimesh(objectType, world.voxelTypes), colliderMesh: buildObjectTrimesh(objectType, world.voxelTypes),
boundingBox: computeBoundingBox(objectType),
}, },
pendingActions: {},
}; };
} }
export function depopulateRuntimeObject(object: RuntimeObjectInstance, _world: World): ObjectInstance { export function depopulateRuntimeObject(object: RuntimeObjectInstance, _world: World): ObjectInstance {

View File

@ -18,7 +18,14 @@ export class ObjectApi {
this.scene = scene; this.scene = scene;
} }
public applyImpulse(direction: V3, amplitude: number) { public applyImpulse(targetPosition: V3, amplitude: number) {
const [px, py, pz] = this.object.position;
const dx = targetPosition[0] - px;
const dy = targetPosition[1] - py;
const dz = targetPosition[2] - pz;
const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (len < 1e-6) return;
const direction: V3 = [dx / len, dy / len, dz / len];
this.object.pendingActions.impulse = { direction, amplitude }; this.object.pendingActions.impulse = { direction, amplitude };
} }
} }