Compare commits

..

2 Commits

Author SHA1 Message Date
azykov@mail.ru 63bd5e3715
GameObjectInstance.version solution to circular object rerenders 2026-06-03 20:54:03 +03:00
azykov@mail.ru 2dd3aff737
ernamed radialvelocity to angularvelocity 2026-06-03 20:53:00 +03:00
6 changed files with 117 additions and 58 deletions

View File

@ -1,13 +1,13 @@
import { observer } from "mobx-react-lite";
import type { ObjectInstance, R3, Runtime } from "../types";
import { useRef, type RefObject } from "react";
import { useMemo, useRef, type RefObject } from "react";
import type { Group } from "three";
import { TransformControls, useHelper } from "@react-three/drei";
import { BoxHelper } from "three";
import type { ThreeEvent } from "@react-three/fiber";
import { state } from "../state";
import { nextSelectionEditMode } from "../state/worldEditorState";
import { ObjectViewInternal } from "./ObjectViewInternal";
import { ObjectViewInternal, type ObjectViewInternalHandle } from "./ObjectViewInternal";
type ObjectEditorViewProps = {
object: Runtime<ObjectInstance>;
@ -15,17 +15,23 @@ type ObjectEditorViewProps = {
type SelectionOverlayProps = {
objectId: string;
groupRef: RefObject<Group | null>;
ref: RefObject<ObjectViewInternalHandle | null>;
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, groupRef, onTransformEnd }: SelectionOverlayProps) {
const SelectionOverlay = observer(function ({ objectId, ref, onTransformEnd }: SelectionOverlayProps) {
const isSelected = state.worldEditor.isEnabled &&
state.worldEditor.selectedObjectId === objectId;
const selectionMode = isSelected ? state.worldEditor.selectedObjectMode : undefined;
// Stable virtual ref that reads through to the group inside the handle
const groupRef = useMemo<RefObject<Group | null>>(
() => ({ get current() { return ref.current?.group ?? null; } }),
[ref],
);
useHelper(isSelected ? groupRef : { current: null } as any, BoxHelper, 'white');
if (!isSelected || selectionMode === undefined || !groupRef.current)
@ -41,7 +47,7 @@ const SelectionOverlay = observer(function ({ objectId, groupRef, onTransformEnd
});
export const ObjectEditorView = observer(function ({ object }: ObjectEditorViewProps) {
const groupRef = useRef<Group>(null);
const dataRef = useRef<ObjectViewInternalHandle>(null);
// Only observes world object types — not selection state — so won't
// re-render when a different object is selected.
@ -61,7 +67,7 @@ export const ObjectEditorView = observer(function ({ object }: ObjectEditorViewP
}
function handleTransformEnd() {
const group = groupRef.current;
const group = dataRef.current?.group;
if (group)
state.worldEditor.setObjectTransform(
object.id,
@ -74,11 +80,11 @@ export const ObjectEditorView = observer(function ({ object }: ObjectEditorViewP
return (<>
<SelectionOverlay
objectId={object.id}
groupRef={groupRef}
ref={dataRef}
onTransformEnd={handleTransformEnd}
/>
<ObjectViewInternal
ref={groupRef}
ref={dataRef}
object={object}
isEditor
objectType={objectType}

View File

@ -1,43 +1,83 @@
import { observer } from "mobx-react-lite";
import type { ObjectType, RuntimeObjectInstance } from "../types";
import { forwardRef } from "react";
import type { Group } from "three";
import type { ObjectType, RuntimeGameObjectInstance, RuntimeObjectInstance } from "../types";
import { useEffect, useImperativeHandle, useRef, type Ref } from "react";
import { Euler, Quaternion, type Group } from "three";
import { Instance, Instances } from "@react-three/drei";
import type { ThreeEvent } from "@react-three/fiber";
import { ConvexHullCollider } from "@react-three/rapier";
import { ConvexHullCollider, RapierRigidBody } from "@react-three/rapier";
import { SyncRigidBody } from "./SyncRigidBody";
import { reaction, runInAction } from "mobx";
import { v3toRapier } from "../utils";
export type ObjectViewInternalHandle = {
group: Group | null;
rb: RapierRigidBody | null;
}
type ObjectViewInternalProps = {
object: Omit<RuntimeObjectInstance, 'typeId'>;
objectType: ObjectType;
isEditor?: boolean;
onClick?: (e: ThreeEvent<MouseEvent>) => void;
ref?: Ref<ObjectViewInternalHandle>;
}
export const ObjectViewInternal = observer(forwardRef<Group | null, ObjectViewInternalProps>(
function ({ object, objectType, ...props }, ref) {
// not observer, instead reacts only to object.version field
export const ObjectViewInternal = function ({ object, objectType, ref, ...props }: ObjectViewInternalProps) {
const rbRef = useRef<RapierRigidBody>(null);
const groupRef = useRef<Group | null>(null);
useImperativeHandle(ref, () => ({ group: groupRef.current, rb: rbRef.current }));
useEffect(
() => reaction(
() => 'version' in object ? object.version : 0,
() => {
if (!rbRef.current)
return;
const gameObj = object as RuntimeGameObjectInstance;
// position
rbRef.current.setTranslation(v3toRapier(gameObj.position), true);
// rotation
const euler = new Euler(gameObj.rotation[0], gameObj.rotation[1], gameObj.rotation[2]);
const q = new Quaternion().setFromEuler(euler);
rbRef.current.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, true);
// scale
groupRef.current?.scale.set(...gameObj.scale);
rbRef.current.setLinvel(v3toRapier(gameObj.linearVelocity), true);
rbRef.current.setAngvel(v3toRapier(gameObj.angularVelocity), true);
},
),
[object.id]
);
return (
<SyncRigidBody
ref={rbRef}
colliders={false}
gravityScale={object.physics ? 1 : 0.1}
onSync={(_data) => {
// TODO rewrite so that is does not trigger re-render
// runInAction(() => {
// object.position = data.position;
// object.rotation = data.rotation;
// if (!props.isEditor) {
// (object as RuntimeGameObjectInstance).linearVelocity = data.linearVelocity;
// (object as RuntimeGameObjectInstance).radialVelocity = data.radialVelocity;
// }
// });
type={object.physics ? 'dynamic' : 'fixed'}
gravityScale={object.gravityScale}
position={object.position}
rotation={object.rotation}
onSync={(data) => {
runInAction(() => {
object.position = data.position;
object.rotation = data.rotation;
if (!props.isEditor) {
(object as RuntimeGameObjectInstance).linearVelocity = data.linearVelocity;
(object as RuntimeGameObjectInstance).angularVelocity = data.radialVelocity;
}
// do not change object.version to not trigger rerender
});
}}
>
<group
ref={ref}
ref={groupRef}
name={`${object.id} (${objectType.id} instance)`}
position={object.position}
rotation={object.rotation}
scale={object.scale}
onClick={props.onClick}
>
@ -54,5 +94,4 @@ export const ObjectViewInternal = observer(forwardRef<Group | null, ObjectViewIn
</group>
</SyncRigidBody>
);
}
));
}

View File

@ -19,6 +19,8 @@ export class GameState {
);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// @ts-ignore
private withoutAutoSave(fn: () => void) {
this._stopAutoSave();
fn();
@ -100,7 +102,7 @@ export class GameState {
public setCharacterTransform(
transform: Pos3,
linearVelocity?: V3,
radialVelocity?: R3,
angularVelocity?: R3,
): void {
if (this.isPaused)
return;
@ -108,8 +110,8 @@ export class GameState {
this.scene.character.transform = transform;
if (linearVelocity)
this.scene.character.linearVelocity = linearVelocity;
if (radialVelocity)
this.scene.character.radialVelocity = radialVelocity;
if (angularVelocity)
this.scene.character.angularVelocity = angularVelocity;
// console.log(`changed character to ${JSON.stringify(this.scene.character)}`);
}

View File

@ -43,8 +43,9 @@ export class WorldState {
.map((_, idx) => ({
id: `obj${idx}`,
typeId: 'wolf',
physics: true,
gravityScale: 1,
position: [idx * 10 - 10, 0, 0],
position: [idx * 10 - 10, 5, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
} as ObjectInstance));

View File

@ -19,8 +19,9 @@ export type ObjectInstance = {
}
export type GameObjectData = {
version: number; // increment to force three and physics changes
linearVelocity: V3;
radialVelocity: R3;
angularVelocity: R3;
}
export type GameObjectInstance = ObjectInstance & GameObjectData;

View File

@ -1,4 +1,6 @@
import { v4 as uuid } from 'uuid';
import type { V3 } from '../types';
import { type Vector3Object } from '@react-three/rapier';
export function clone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
@ -7,3 +9,11 @@ export function clone<T>(obj: T): T {
export function randomId(): string {
return uuid();
}
export function v3toRapier(value: V3): Vector3Object {
return {
x: value[0],
y: value[1],
z: value[2],
}
}