Compare commits

...

5 Commits

Author SHA1 Message Date
azykov@mail.ru 487a894fe5
better player indicator
better initial editor camera location
2026-06-02 23:27:23 +03:00
azykov@mail.ru 858b47e71b
fixed large voxelGroup rendering
added terrain object
2026-06-02 23:19:15 +03:00
azykov@mail.ru 32386ba70f
first-person game does not render character anymore 2026-06-02 23:02:33 +03:00
azykov@mail.ru 9b7cce5c79
player camera and controls during game 2026-06-02 23:00:19 +03:00
azykov@mail.ru c52458391d
game state separated from world state 2026-06-02 21:31:01 +03:00
18 changed files with 236 additions and 97 deletions

View File

@ -3,14 +3,20 @@ import type { Character } from "../types";
export const CharacterView = observer(function ({ character }: { character: Character }) { export const CharacterView = observer(function ({ character }: { character: Character }) {
const pos = character.position; const pos = character.transform.position;
return <mesh return <group
position={[pos[0] + 0.5, pos[1] + 0.5, pos[2] + 0.5]} position={[pos[0] + 0.5, pos[1] + 0.5, pos[2] + 0.5]}
rotation={character.look} rotation={character.transform.look}
> >
<boxGeometry args={[0.8, 0.8, 0.8]} /> {/* <mesh>
<meshStandardMaterial color="yellow" /> <boxGeometry args={[0.8, 0.8, 0.8]} />
</mesh > <meshStandardMaterial color="yellow" />
</mesh> */}
<mesh position={[0, 0, 0]} rotation={[-Math.PI / 2, -Math.PI / 4, 0]}>
<coneGeometry args={[0.55, 0.8, 4]} />
<meshStandardMaterial color="yellow" />
</mesh>
</group>
}); });

View File

@ -1,10 +1,46 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { SceneView } from "./SceneView"; import { SceneView } from "./SceneView";
import { state } from "../state"; import { state } from "../state";
import { useFrame } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { PointerLockControls, useKeyboardControls } from "@react-three/drei";
import { useEffect, useRef } from "react";
function PlayerMovement() {
const [, get] = useKeyboardControls();
const dirty = useRef(false);
useFrame(({ camera }, dt) => {
const { forward, backward, left, right } = get();
const speed = 5 * dt;
if (forward) { camera.translateZ(-speed); dirty.current = true; }
if (backward) { camera.translateZ( speed); dirty.current = true; }
if (left) { camera.translateX(-speed); dirty.current = true; }
if (right) { camera.translateX( speed); dirty.current = true; }
if (!dirty.current) return;
dirty.current = false;
const [rx, ry, rz] = camera.rotation.toArray();
state.game?.setCharacterTransform({
position: camera.position.toArray(),
look: [rx, ry, rz],
});
});
return <PointerLockControls onChange={() => { dirty.current = true; }} />;
}
export const GameView = observer(function () { export const GameView = observer(function () {
const game = state.game; const game = state.game;
const { camera } = useThree();
useEffect(() => {
if (!game)
return;
const { position, look } = game.camera;
camera.position.set(position[0], position[1], position[2]);
camera.rotation.set(look[0], look[1], look[2]);
}, []);
useFrame((_, delta) => { useFrame((_, delta) => {
state.game?.tick(delta); state.game?.tick(delta);
@ -13,5 +49,8 @@ export const GameView = observer(function () {
if (!game) if (!game)
return null; return null;
return <SceneView scene={game.scene} />; return (<>
<PlayerMovement />
<SceneView scene={game.scene} renderCharacter={false} />
</>);
}); });

View File

@ -89,8 +89,8 @@ export const ObjectView = observer(function ({ object }: ObjectViewProps) {
onClick={handleClick} onClick={handleClick}
> >
{ {
voxelGroups(objectType).map((vg) => ( voxelGroups(objectType).map((vg) => {
<Instances key={vg.id}> return <Instances key={vg.id} limit={vg.positions.length}>
<boxGeometry args={[1, 1, 1]} /> <boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={vg.color} opacity={vg.opacity} transparent={vg.opacity < 1} /> <meshStandardMaterial color={vg.color} opacity={vg.opacity} transparent={vg.opacity < 1} />
{ {
@ -98,7 +98,7 @@ export const ObjectView = observer(function ({ object }: ObjectViewProps) {
.map((pos, i) => <Instance key={i} position={pos} />) .map((pos, i) => <Instance key={i} position={pos} />)
} }
</Instances> </Instances>
)) })
} }
</group> </group>
</>); </>);

View File

@ -1,24 +1,12 @@
import { useLayoutEffect, useRef } from 'react'; import { useRef } from 'react';
import type React from 'react'; import type React from 'react';
import { useThree } from '@react-three/fiber';
import { Grid, OrbitControls } from '@react-three/drei'; import { Grid, OrbitControls } from '@react-three/drei';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { state } from '../state'; import { state } from '../state';
import { SceneView } from './SceneView'; import { SceneView } from './SceneView';
import type { Pos3 } from '../types/3d';
import { type OrthographicCamera, type PerspectiveCamera } from 'three'; import { type OrthographicCamera, type PerspectiveCamera } from 'three';
import { CameraSync } from './tools/CameraSync';
const CameraSync = observer(function ({ camera }: { camera: Pos3 }) {
const { camera: threeCamera } = useThree();
useLayoutEffect(() => {
threeCamera.position.set(camera.position[0], camera.position[1], camera.position[2]);
threeCamera.rotation.set(camera.look[0], camera.look[1], camera.look[2]);
threeCamera.updateProjectionMatrix();
});
return null;
});
export const SceneEditorView = observer(function () { export const SceneEditorView = observer(function () {
const controlsRef = useRef<React.ComponentRef<typeof OrbitControls>>(null); const controlsRef = useRef<React.ComponentRef<typeof OrbitControls>>(null);
@ -68,6 +56,6 @@ export const SceneEditorView = observer(function () {
sectionColor="white" sectionColor="white"
infiniteGrid infiniteGrid
/> />
<SceneView scene={state.worldEditor.scene} /> <SceneView scene={state.worldEditor.scene} renderCharacter={true} />
</>); </>);
}); });

View File

@ -5,14 +5,15 @@ import { ObjectView } from "./ObjectView";
type SceneViewProps = { type SceneViewProps = {
scene: Scene, scene: Scene,
renderCharacter: boolean;
} }
export const SceneView = observer(function ({ scene }: SceneViewProps) { export const SceneView = observer(function ({ scene, renderCharacter }: SceneViewProps) {
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(scene.objects).map((obj) => {Object.values(scene.objects).map((obj) =>
<ObjectView key={obj.id} object={obj} />)} <ObjectView key={obj.id} object={obj} />)}
<CharacterView character={scene.character} /> {renderCharacter && <CharacterView character={scene.character} />}
</>); </>);
}); });

View File

@ -1,5 +1,5 @@
import { Canvas, useFrame, useThree } from '@react-three/fiber'; import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { Stats } from '@react-three/drei'; import { KeyboardControls, Stats } from '@react-three/drei';
import { useRef } from 'react'; import { useRef } from 'react';
function RenderInfoUpdater({ domRef }: { domRef: React.RefObject<HTMLDivElement | null> }) { function RenderInfoUpdater({ domRef }: { domRef: React.RefObject<HTMLDivElement | null> }) {
@ -30,6 +30,12 @@ export const ThreeView = observer(function () {
const isGame = state.isGamePlaying; const isGame = state.isGamePlaying;
const infoRef = useRef<HTMLDivElement>(null); const infoRef = useRef<HTMLDivElement>(null);
return ( return (
<KeyboardControls map={[
{ name: 'forward', keys: ['ArrowUp', 'w', 'W'] },
{ name: 'backward', keys: ['ArrowDown', 's', 'S'] },
{ name: 'left', keys: ['ArrowLeft', 'a', 'A'] },
{ name: 'right', keys: ['ArrowRight', 'd', 'D'] },
]}>
<div style={{ position: 'relative', width: '800px', height: '600px', border: '1px solid #333', borderRadius: '8px', overflow: 'hidden' }}> <div style={{ position: 'relative', width: '800px', height: '600px', border: '1px solid #333', borderRadius: '8px', overflow: 'hidden' }}>
<Canvas <Canvas
// camera={state.world.character.camera} // camera={state.world.character.camera}
@ -49,16 +55,17 @@ export const ThreeView = observer(function () {
{ {
state.game state.game
? <> ? <>
<button onClick={() => state.game!.stop()}><IconStop /></button> <button onClick={() => state.stopGame()}><IconStop /></button>
{ {
state.game!.isPaused state.game!.isPaused
? <button onClick={() => state.game!.resume()}><IconPlay /></button> ? <button onClick={() => state.game!.resume()}><IconPlay /></button>
: <button onClick={() => state.game!.pause()}><IconPause /></button> : <button onClick={() => state.game!.pause()}><IconPause /></button>
} }
</> </>
: <button onClick={() => state.world.play()}><IconPlay /></button> : <button onClick={() => state.startGame()}><IconPlay /></button>
} }
</div> </div>
</div> </div>
</KeyboardControls>
) )
}); });

View File

@ -0,0 +1,16 @@
import { useThree } from "@react-three/fiber";
import type { Pos3 } from "../../types/3d";
import { useLayoutEffect } from "react";
import { observer } from "mobx-react-lite";
export const CameraSync = observer(function ({ camera }: { camera: Pos3 }) {
const { camera: threeCamera } = useThree();
useLayoutEffect(() => {
threeCamera.position.set(camera.position[0], camera.position[1], camera.position[2]);
threeCamera.rotation.set(camera.look[0], camera.look[1], camera.look[2]);
threeCamera.updateProjectionMatrix();
});
return null;
});

24
src/model/gameFactory.ts Normal file
View File

@ -0,0 +1,24 @@
import type { Game, World } from "../types";
import { clone } from "../utils";
export class GameFactory {
public static create(world: World): Game {
return {
paused: false,
time: 0,
scene: clone(world.initialScene),
}
}
public static load(): Game | undefined {
const json = localStorage.getItem("game");
if (json) {
return JSON.parse(json) as Game;
}
}
public static save(game: Game): void {
localStorage.setItem("game", JSON.stringify(game));
}
}

View File

@ -0,0 +1,18 @@
import type { Voxel } from "../../types/voxel";
export function terrainXZ(width: number, length: number): Voxel[] {
const vs = Array(width)
.fill(0)
.flatMap((_, x) =>
Array(length)
.fill(0)
.map((_, z) => ({
typeId: 'dirt',
position: [x - Math.floor(width / 2), 0, z - Math.floor(length / 2)],
color: 'green',
} as Voxel))
);
return vs;
}

View File

@ -9,14 +9,13 @@ export class WorldFactory {
voxelTypes: DEFAULT_VOXEL_TYPES, voxelTypes: DEFAULT_VOXEL_TYPES,
initialScene: { initialScene: {
character: { character: {
position: [0, 0, 0], transform: {
look: [0, 0, 0], position: [0, 0, 0],
look: [0, 0, 0],
},
}, },
objects: {}, objects: {},
}, },
state: {
playing: false,
},
gameRules: { gameRules: {
gravity: true, gravity: true,
}, },

View File

@ -1,31 +1,45 @@
import { makeAutoObservable } from "mobx"; import { makeAutoObservable, reaction, toJS } from "mobx";
import type { WorldState } from "./worldState"; import type { WorldState } from "./worldState";
import type { RunningGameState, Scene } from "../types"; import type { Game, Scene } from "../types";
import type { Pos3 } from "../types/3d"; import type { Pos3, R3, V3 } from "../types/3d";
import type { CameraProps } from "@react-three/fiber"; import type { CameraProps } from "@react-three/fiber";
import { GameFactory } from "../model/gameFactory";
export class GameState { export class GameState {
private readonly world: WorldState; private readonly world: WorldState;
public data: Game;
private startAutoSave() {
return reaction(() => toJS(this.data), (data) => this.saveData(data), { delay: 5000 });
}
private withoutAutoSave(fn: () => void) {
this._stopAutoSave();
fn();
this._stopAutoSave = this.startAutoSave();
}
private _stopAutoSave: () => void = () => { };
constructor(world: WorldState) { constructor(world: WorldState) {
this.world = world; this.world = world;
this.data = GameFactory.create(toJS(this.world.data));
this._stopAutoSave = this.startAutoSave();
makeAutoObservable(this); makeAutoObservable(this);
} }
public get state(): RunningGameState {
return this.world.data.state as RunningGameState;
}
public get isPaused(): boolean { public get isPaused(): boolean {
return this.state.paused; return this.data.paused;
} }
public get scene(): Scene { public get scene(): Scene {
return this.state.scene; return this.data.scene;
} }
public get camera(): Pos3 { public get camera(): Pos3 {
return this.scene.character; return this.scene.character.transform;
} }
public get cameraAsThree(): CameraProps { public get cameraAsThree(): CameraProps {
@ -37,19 +51,44 @@ export class GameState {
} }
} }
// public load() {
// console.log('Loading game...');
// this.withoutAutoSave(() => {
// this.data = GameFactory.load() ?? GameFactory.create(this.world.data);
// });
// }
private saveData(data: Game): void {
console.log('Saving game...');
const stack = new Error('Saving game...').stack!.split('\n').slice(1);
const { ...debug } = toJS(data);
console.dir({ stack, debug });
GameFactory.save(toJS(this.data));
}
public save(): void {
this.saveData(this.data);
}
public resume(): void { public resume(): void {
this.state.paused = false; this.data.paused = false;
} }
public pause(): void { public pause(): void {
this.state.paused = true; this.data.paused = true;
} }
public stop(): void { public setCharacterTransform(transform: Pos3): void {
this.world.data.state = { playing: false }; if (this.isPaused)
return;
this.scene.character.transform = transform;
} }
public tick(_deltaTime: number): void { public tick(deltaTime: number): void {
//TODO if (this.isPaused)
return;
this.data.time += deltaTime;
} }
} }

View File

@ -6,6 +6,7 @@ import { GameState } from "./gameState";
export class RootState { export class RootState {
public readonly world = new WorldState(); public readonly world = new WorldState();
public readonly worldEditor: WorldEditorState; public readonly worldEditor: WorldEditorState;
public game: GameState | undefined;
constructor() { constructor() {
this.worldEditor = new WorldEditorState(this.world); this.worldEditor = new WorldEditorState(this.world);
@ -19,12 +20,22 @@ export class RootState {
} }
public get isGamePlaying(): boolean { public get isGamePlaying(): boolean {
return this.world.data.state.playing; return this.game !== undefined;
} }
public get game(): GameState | undefined { public startGame(): void {
if (this.isGamePlaying) if (this.game)
return new GameState(this.world); this.stopGame();
this.game = new GameState(this.world),
state.worldEditor.resetSelectedObject();
}
public stopGame(): void {
if (!this.game)
return;
this.game.pause();
this.game = undefined;
} }
} }

View File

@ -5,7 +5,7 @@ import type { VoxelType } from "../types/voxel";
import { state } from "./rootState"; import { state } from "./rootState";
import { DEFAULT_VOXEL_TYPES } from "../model/defaultVoxelTypes"; import { DEFAULT_VOXEL_TYPES } from "../model/defaultVoxelTypes";
import { wolf } from "../model/objectPrefabs/wolf"; import { wolf } from "../model/objectPrefabs/wolf";
import { clone } from "../utils"; import { terrainXZ } from "../model/objectPrefabs/terrain";
export class WorldState { export class WorldState {
public data: World = WorldFactory.create(); public data: World = WorldFactory.create();
@ -58,25 +58,38 @@ export class WorldState {
name: 'Wolf', name: 'Wolf',
voxels: wolf, voxels: wolf,
}, },
terrain: {
id: 'terrain',
name: 'Terrain',
voxels: terrainXZ(50, 50),
}
}, },
voxelTypes: DEFAULT_VOXEL_TYPES, voxelTypes: DEFAULT_VOXEL_TYPES,
editorCamera: { editorCamera: {
position: [0, 2, 10], position: [-9, 11, 30],
look: [0, 0, 0], look: [-0.52, -0.35, -0.2],
}, },
initialScene: { initialScene: {
character: { character: {
position: [0, 0, 0], transform: {
look: [0, 0, 0], position: [0, 5, 20],
look: [0, 0, 0],
}
},
objects: {
terrain: {
id: 'terrain',
typeId: 'terrain',
position: [0, -1, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
...objectMap,
}, },
objects: objectMap,
}, },
gameRules: { gameRules: {
gravity: true, gravity: true,
}, },
state: {
playing: false,
},
}; };
console.log(objects); console.log(objects);
state.worldEditor.resetSelectedObject(); state.worldEditor.resetSelectedObject();
@ -109,18 +122,4 @@ export class WorldState {
public getVoxelTypeById(id: string): VoxelType | undefined { public getVoxelTypeById(id: string): VoxelType | undefined {
return this.data.voxelTypes[id]; return this.data.voxelTypes[id];
} }
public play(): void {
if (state.game)
state.game.resume();
else {
this.data.state = {
playing: true,
paused: false,
time: 0,
scene: clone(this.data.initialScene),
}
state.worldEditor.resetSelectedObject();
}
}
} }

View File

@ -1,4 +1,5 @@
import type { Pos3 } from "./3d"; import type { Pos3 } from "./3d";
export type Character = Pos3 & { export type Character = {
transform: Pos3,
} }

7
src/types/game.ts Normal file
View File

@ -0,0 +1,7 @@
import type { Scene } from "./scene";
export type Game = {
paused: boolean;
time: number;
scene: Scene;
}

View File

@ -1,14 +0,0 @@
import type { Scene } from "./scene";
export type StoppedGameState = {
playing: false;
}
export type RunningGameState = {
playing: true;
paused: boolean;
time: number;
scene: Scene;
}
export type GameState = StoppedGameState | RunningGameState;

View File

@ -2,7 +2,7 @@ export * from './object';
export * from './scene'; export * from './scene';
export * from './world'; export * from './world';
export * from './gameRules'; export * from './gameRules';
export * from './gameState'; export * from './game';
export * from './character'; export * from './character';

View File

@ -1,5 +1,4 @@
import type { GameRules } from "./gameRules"; import type { GameRules } from "./gameRules";
import type { GameState } from "./gameState";
import type { ObjectType } from "./object"; import type { ObjectType } from "./object";
import type { Scene } from "./scene"; import type { Scene } from "./scene";
import type { Pos3 } from "./3d"; import type { Pos3 } from "./3d";
@ -11,5 +10,4 @@ export type World = {
editorCamera: Pos3; editorCamera: Pos3;
initialScene: Scene; initialScene: Scene;
gameRules: GameRules; gameRules: GameRules;
state: GameState;
} }