Compare commits

...

2 Commits

Author SHA1 Message Date
azykov@mail.ru 90162d47cf
sidebar 2026-06-04 10:50:06 +03:00
azykov@mail.ru 8a23a49863
fullscreen and no pull to refresh 2026-06-04 09:52:58 +03:00
9 changed files with 166 additions and 47 deletions

View File

@ -2,7 +2,7 @@ import { BrowserRouter, Link, Outlet, Route, Routes, useNavigate, useParams } fr
import { useEffect } from 'react'; import { useEffect } from 'react';
import { reaction } from 'mobx'; import { reaction } from 'mobx';
import { ThreeView } from './components/ThreeView'; import { ThreeView } from './components/ThreeView';
import { Toolbar } from './components/Toolbar'; import { Panel } from './components/Panel';
import { state } from './state'; import { state } from './state';
import './App.scss'; import './App.scss';
@ -59,7 +59,7 @@ function EditorLayout() {
<> <>
<Outlet /> <Outlet />
<ThreeView /> <ThreeView />
<Toolbar /> <Panel side="left" />
</> </>
); );
} }

69
src/components/Panel.scss Normal file
View File

@ -0,0 +1,69 @@
.panel {
position: fixed;
top: 0;
width: 30%;
height: 100vh;
padding: 10px;
z-index: 10;
box-sizing: border-box;
display: flex;
flex-direction: column;
pointer-events: none;
&.left {
left: 0;
}
&.right {
right: 0;
}
&>.container {
flex: 1;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
padding: 4px;
display: flex;
flex-direction: column;
gap: 0.25em;
&>*:not(.debug):not(.gap) {
pointer-events: all;
}
&>.gap {
flex: 1;
}
&>.debug {
border: 1px solid #ffffff20;
padding: 2px;
font-size: 75%;
display: flex;
gap: 0.5em;
&>.chart {
display: inline;
& div {
display: inline;
position: relative !important;
z-index: unset !important;
opacity: 1 !important;
}
}
&>.metrics {
display: flex;
flex-wrap: wrap;
gap: 0.25em 0.5em;
&>* {
white-space: pre;
}
}
}
}
}

33
src/components/Panel.tsx Normal file
View File

@ -0,0 +1,33 @@
import { observer } from "mobx-react-lite";
import './Panel.scss';
import { RenderInfoView } from "./RenderInfoView";
import { state } from "../state";
export type PanelProps = {
side?: 'left' | 'right';
}
export const Panel = observer(function ({ side = 'left' }: PanelProps) {
function handleCloneTest1Object(): void {
state.worldEditor.addObjectCloneAtRandomPosition('test1');
}
function handleLoadWorld(): void {
state.world.load();
}
function handleLoadMockWorld(): void {
state.world.loadMock();
}
return <div className={`panel ${side}`}>
<div className="container">
<button onClick={handleLoadWorld}>Load</button>
<button onClick={handleLoadMockWorld}>Load mock world</button>
<button onClick={handleCloneTest1Object}>Clone test1</button>
<div className="gap" />
<div className="debug"><RenderInfoView /></div>
</div>
</div >
});

View File

@ -0,0 +1,29 @@
import { useEffect, useRef } from "react";
import { observer } from "mobx-react-lite";
import { state } from "../state";
import { chartRef } from "./chartRef";
export const RenderInfoView = observer(function () {
const info = state.renderInfo;
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.appendChild(chartRef.current);
return () => { el.removeChild(chartRef.current); };
}, []);
return <>
<div className="chart" ref={containerRef} />
{info != null && (
<div className="metrics">
<span>draw: {info.calls}</span>
<span>tri: {info.triangles}</span>
<span>shaders: {info.shaders}</span>
<span>geometries: {info.geometries}</span>
<span>textures: {info.textures}</span>
</div>
)}
</>
});

View File

@ -1,19 +1,22 @@
import { Canvas, useFrame, useThree } from '@react-three/fiber'; import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { KeyboardControls, Stats } from '@react-three/drei'; import { KeyboardControls, Stats } from '@react-three/drei';
import { useRef } from 'react'; import { action } from 'mobx';
import { chartRef } from './chartRef';
function RenderInfoUpdater({ domRef }: { domRef: React.RefObject<HTMLDivElement | null> }) { function RenderInfoUpdater() {
const { gl } = useThree(); const { gl } = useThree();
useFrame(() => { useFrame(action(() => {
if (!domRef.current)
return;
const { calls, triangles } = gl.info.render; const { calls, triangles } = gl.info.render;
const shaders = gl.info.programs?.length ?? 0; const shaders = gl.info.programs?.length ?? 0;
const { geometries, textures } = gl.info.memory; const { geometries, textures } = gl.info.memory;
state.setRenderInfo({
domRef.current.textContent = `draw: ${calls} | tri: ${triangles} | shaders: ${shaders} | geometries: ${geometries} | textures: ${textures}`; calls,
triangles,
shaders,
geometries,
textures,
}); });
}));
return null; return null;
} }
@ -21,6 +24,7 @@ import { observer } from 'mobx-react-lite';
import { state } from '../state'; import { state } from '../state';
import { GameView } from './GameView'; import { GameView } from './GameView';
import { SceneEditorView } from './SceneEditorView'; import { SceneEditorView } from './SceneEditorView';
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 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 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 >;
@ -28,7 +32,6 @@ const IconPlay = () => <svg viewBox="0 0 14 14"><polygon points="3,1 13,7 3,13"
export const ThreeView = observer(function () { export const ThreeView = observer(function () {
const isGame = state.isGamePlaying; const isGame = state.isGamePlaying;
const infoRef = useRef<HTMLDivElement>(null);
return ( return (
<KeyboardControls map={[ <KeyboardControls map={[
{ name: 'forward', keys: ['ArrowUp', 'w', 'W'] }, { name: 'forward', keys: ['ArrowUp', 'w', 'W'] },
@ -37,21 +40,15 @@ export const ThreeView = observer(function () {
{ name: 'right', keys: ['ArrowRight', 'd', 'D'] }, { name: 'right', keys: ['ArrowRight', 'd', 'D'] },
{ name: 'jump', keys: ['Space'] }, { name: 'jump', keys: ['Space'] },
]}> ]}>
<div style={{ position: 'relative', width: '800px', height: '600px', border: '1px solid #333', borderRadius: '8px', overflow: 'hidden' }}> <div style={{ position: 'fixed', inset: 0, overflow: 'hidden' }}>
<Canvas <Canvas
// camera={state.world.character.camera} // camera={state.world.character.camera}
onPointerMissed={() => state.worldEditor.resetSelectedObject()} onPointerMissed={() => state.worldEditor.resetSelectedObject()}
> >
<Stats /> <Stats parent={chartRef as RefObject<HTMLElement>} />
<RenderInfoUpdater domRef={infoRef} /> <RenderInfoUpdater />
{isGame ? <GameView /> : <SceneEditorView />} {isGame ? <GameView /> : <SceneEditorView />}
</Canvas> </Canvas>
<div ref={infoRef} style={{
position: 'absolute', bottom: 8, left: 8,
color: 'white', fontSize: 11, fontFamily: 'monospace',
background: 'rgba(0,0,0,0.5)', padding: '2px 6px', borderRadius: 3,
pointerEvents: 'none',
}} />
<div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4 }}> <div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4 }}>
{ {
state.game state.game

View File

@ -1,24 +0,0 @@
import { observer } from "mobx-react-lite";
import { state } from "../state";
export const Toolbar = observer(function () {
function handleCloneTest1Object(): void {
state.worldEditor.addObjectCloneAtRandomPosition('test1');
}
function handleLoadWorld(): void {
state.world.load();
}
function handleLoadMockWorld(): void {
state.world.loadMock();
}
return <div className="toolbar">
<button onClick={handleLoadWorld}>Load</button>
<button onClick={handleLoadMockWorld}>Load mock world</button>
<button onClick={handleCloneTest1Object}>Clone test1</button>
</div>
});

View File

@ -0,0 +1 @@
export const chartRef = { current: document.createElement('span') };

View File

@ -48,10 +48,9 @@
} }
#root { #root {
width: 1126px; width: 100%;
max-width: 100%; max-width: 100%;
margin: 0 auto; margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border); border-inline: 1px solid var(--border);
min-height: 100svh; min-height: 100svh;
display: flex; display: flex;
@ -59,8 +58,10 @@
box-sizing: border-box; box-sizing: border-box;
} }
html,
body { body {
margin: 0; margin: 0;
overscroll-behavior: none;
} }
svg { svg {

View File

@ -3,10 +3,19 @@ import { WorldState } from "./worldState";
import { WorldEditorState } from "./worldEditorState"; import { WorldEditorState } from "./worldEditorState";
import { GameState } from "./gameState"; import { GameState } from "./gameState";
export type RenderInfo = {
calls: number,
triangles: number,
shaders: number,
geometries: number,
textures: number,
}
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; public game: GameState | undefined;
public renderInfo: RenderInfo | undefined;
constructor() { constructor() {
this.worldEditor = new WorldEditorState(this.world); this.worldEditor = new WorldEditorState(this.world);
@ -37,6 +46,10 @@ export class RootState {
this.game.pause(); this.game.pause();
this.game = undefined; this.game = undefined;
} }
public setRenderInfo(value: RenderInfo | undefined) {
this.renderInfo = value;
}
} }
export const state = new RootState(); export const state = new RootState();