refactor(atelier): remove 3D simulation (StagePane/scene/puzzles/modes/simStore, three deps)
This commit is contained in:
@@ -10,8 +10,6 @@
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"@zacus/scenario-engine": "workspace:*",
|
||||
"@zacus/shared": "workspace:*",
|
||||
"@zacus/ui": "workspace:*",
|
||||
@@ -19,13 +17,11 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"three": "^0.171.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/three": "^0.184.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.11",
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useLiveDiff } from './lib/useLiveDiff.js';
|
||||
import { useShareLink } from './lib/useShareLink.js';
|
||||
import { useEditorStore } from './stores/editorStore.js';
|
||||
import { useRuntimeStore } from './stores/runtimeStore.js';
|
||||
import { useSimStore } from './stores/simStore.js';
|
||||
import { useValidationStore } from './stores/validationStore.js';
|
||||
|
||||
// Dev-only test hooks — used by e2e/ specs to drive store state without
|
||||
@@ -17,7 +16,6 @@ if (import.meta.env.DEV) {
|
||||
(window as unknown as { __atelierStores?: unknown }).__atelierStores = {
|
||||
editor: useEditorStore,
|
||||
runtime: useRuntimeStore,
|
||||
sim: useSimStore,
|
||||
validation: useValidationStore,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useSimStore, type SimMode } from '../stores/simStore.js';
|
||||
import { useValidationStore, type ValidationEntry } from '../stores/validationStore.js';
|
||||
|
||||
const MODES: SimMode[] = ['sandbox', 'demo', 'test'];
|
||||
|
||||
const SEVERITY_COLOR: Record<ValidationEntry['severity'], string> = {
|
||||
error: '#fca5a5',
|
||||
warning: '#fcd34d',
|
||||
@@ -10,10 +7,6 @@ const SEVERITY_COLOR: Record<ValidationEntry['severity'], string> = {
|
||||
};
|
||||
|
||||
export function ConsolePane() {
|
||||
const mode = useSimStore((s) => s.mode);
|
||||
const setMode = useSimStore((s) => s.setMode);
|
||||
const simLog = useSimStore((s) => s.simLog);
|
||||
const currentSceneId = useSimStore((s) => s.currentSceneId);
|
||||
const entries = useValidationStore((s) => s.entries);
|
||||
|
||||
const errorCount = entries.filter((e) => e.severity === 'error').length;
|
||||
@@ -22,28 +15,6 @@ export function ConsolePane() {
|
||||
return (
|
||||
<div className="atelier-pane atelier-pane--console">
|
||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 12 }}>
|
||||
<div className="atelier-mode-tabs">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
className={
|
||||
'atelier-mode-tab' + (m === mode ? ' atelier-mode-tab--active' : '')
|
||||
}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{currentSceneId ? (
|
||||
<span
|
||||
data-testid="current-scene"
|
||||
style={{ fontSize: 12, color: '#5085F2', fontWeight: 600 }}
|
||||
>
|
||||
▶ {currentSceneId}
|
||||
</span>
|
||||
) : null}
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, color: '#9a9a9d' }}>
|
||||
{entries.length === 0
|
||||
? 'No issues'
|
||||
@@ -70,11 +41,6 @@ export function ConsolePane() {
|
||||
[{entry.severity}] {entry.message}
|
||||
</div>
|
||||
))}
|
||||
{simLog.map((line, i) => (
|
||||
<div key={`sim-${i}`} style={{ padding: '2px 0', color: '#86efac' }}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type ImperativePanelHandle,
|
||||
} from 'react-resizable-panels';
|
||||
import { EditorPane } from './EditorPane.js';
|
||||
import { StagePane } from './StagePane.js';
|
||||
import { ConsolePane } from './ConsolePane.js';
|
||||
|
||||
/**
|
||||
@@ -65,7 +64,13 @@ export function Layout() {
|
||||
collapsible
|
||||
collapsedSize={0}
|
||||
>
|
||||
<StagePane />
|
||||
<div
|
||||
data-testid="stage-pane"
|
||||
className="atelier-pane"
|
||||
style={{ display: 'grid', placeItems: 'center', color: '#9a9a9d' }}
|
||||
>
|
||||
Banc d'essai (à venir)
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</Panel>
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { lazy, Suspense, useCallback } from 'react';
|
||||
import { useRuntimeStore } from '../stores/runtimeStore.js';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
import { useValidationStore } from '../stores/validationStore.js';
|
||||
|
||||
const RoomSceneLazy = lazy(async () => {
|
||||
const mod = await import('../scene/RoomScene.js');
|
||||
return { default: mod.RoomScene };
|
||||
});
|
||||
|
||||
const SandboxModeLazy = lazy(async () => {
|
||||
const mod = await import('../modes/SandboxMode.js');
|
||||
return { default: mod.SandboxMode };
|
||||
});
|
||||
|
||||
const DemoModeLazy = lazy(async () => {
|
||||
const mod = await import('../modes/DemoMode.js');
|
||||
return { default: mod.DemoMode };
|
||||
});
|
||||
|
||||
const TestModeLazy = lazy(async () => {
|
||||
const mod = await import('../modes/TestMode.js');
|
||||
return { default: mod.TestMode };
|
||||
});
|
||||
|
||||
export function StagePane() {
|
||||
const isStale = useRuntimeStore((s) => s.isStale);
|
||||
const pendingIr = useRuntimeStore((s) => s.pendingIr);
|
||||
const commitPendingIr = useRuntimeStore((s) => s.commitPendingIr);
|
||||
const mode = useSimStore((s) => s.mode);
|
||||
const loadScenario = useSimStore((s) => s.loadScenario);
|
||||
const hasErrors = useValidationStore((s) =>
|
||||
s.entries.some((e) => e.severity === 'error'),
|
||||
);
|
||||
|
||||
const handleRun = useCallback(() => {
|
||||
if (!pendingIr || hasErrors) return;
|
||||
loadScenario(pendingIr);
|
||||
commitPendingIr();
|
||||
}, [pendingIr, hasErrors, loadScenario, commitPendingIr]);
|
||||
|
||||
const ModeOverlay =
|
||||
mode === 'demo' ? DemoModeLazy : mode === 'test' ? TestModeLazy : SandboxModeLazy;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="stage-pane"
|
||||
style={{ position: 'relative', height: '100%', background: '#000' }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="atelier-pane atelier-pane--stage">
|
||||
<span>Loading 3D engine…</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<RoomSceneLazy />
|
||||
<ModeOverlay />
|
||||
</Suspense>
|
||||
{isStale ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRun}
|
||||
disabled={hasErrors}
|
||||
title={
|
||||
hasErrors
|
||||
? 'Corrige les erreurs de validation pour relancer'
|
||||
: 'Recompiler et rejouer la scène avec le scenario édité'
|
||||
}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
padding: '6px 14px',
|
||||
background: hasErrors ? '#6b6b70' : '#fbbf24',
|
||||
color: '#1a1a1d',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: hasErrors ? 'not-allowed' : 'pointer',
|
||||
zIndex: 20,
|
||||
boxShadow: hasErrors ? 'none' : '0 2px 6px rgba(0,0,0,0.4)',
|
||||
}}
|
||||
>
|
||||
{hasErrors ? '⚠ stale (errors)' : '▶ Run'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useFrame, useThree } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
|
||||
// Cinematic camera waypoints (position + target)
|
||||
const WAYPOINTS: Array<{ pos: [number, number, number]; target: [number, number, number]; duration: number }> = [
|
||||
{ pos: [0, 3, 6], target: [0, 0, 0], duration: 5000 },
|
||||
{ pos: [-4, 2, -2], target: [-4, 0, -2], duration: 4000 },
|
||||
{ pos: [-4, 2, 2], target: [-4, 0, 2], duration: 4000 },
|
||||
{ pos: [0, 2, -3.5], target: [0, 0, -3.5], duration: 4000 },
|
||||
{ pos: [4, 2, -2], target: [4, 0, -2], duration: 4000 },
|
||||
{ pos: [4, 2, 2], target: [4, 0, 2], duration: 4000 },
|
||||
{ pos: [0, 4, 0], target: [0, 0, 0], duration: 6000 },
|
||||
];
|
||||
|
||||
/**
|
||||
* DemoMode: auto-play with cinematic camera moving between puzzle stations.
|
||||
* Mount inside a Canvas as a child component (uses useFrame + useThree).
|
||||
*/
|
||||
export function DemoCameraController() {
|
||||
const { camera } = useThree();
|
||||
const mode = useSimStore((s) => s.mode);
|
||||
|
||||
let waypointIndex = 0;
|
||||
let elapsed = 0;
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (mode !== 'demo') return;
|
||||
elapsed += delta * 1000;
|
||||
const wp = WAYPOINTS[waypointIndex % WAYPOINTS.length]!;
|
||||
|
||||
if (elapsed >= wp.duration) {
|
||||
elapsed = 0;
|
||||
waypointIndex = (waypointIndex + 1) % WAYPOINTS.length;
|
||||
}
|
||||
|
||||
const t = Math.min(elapsed / wp.duration, 1);
|
||||
const smoothT = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; // ease in-out
|
||||
|
||||
const targetPos = new THREE.Vector3(...wp.pos);
|
||||
camera.position.lerp(targetPos, smoothT * delta * 2);
|
||||
|
||||
const lookTarget = new THREE.Vector3(...wp.target);
|
||||
camera.lookAt(lookTarget);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* DemoMode component: auto-solves puzzles at intervals for showcase.
|
||||
*/
|
||||
export function DemoMode() {
|
||||
const { solvePuzzle, solvedPuzzles } = useSimStore();
|
||||
const PUZZLE_SEQUENCE = ['P1_SON', 'P2_CIRCUIT', 'P3_QR', 'P4_RADIO', 'P5_MORSE', 'P6_SYMBOLES', 'P7_COFFRE'];
|
||||
|
||||
useEffect(() => {
|
||||
if (solvedPuzzles.length >= PUZZLE_SEQUENCE.length) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const nextPuzzle = PUZZLE_SEQUENCE[solvedPuzzles.length];
|
||||
if (nextPuzzle && !solvedPuzzles.includes(nextPuzzle)) {
|
||||
solvePuzzle(nextPuzzle);
|
||||
}
|
||||
}, 8000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [solvedPuzzles.length, solvePuzzle]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const HUD_STYLE: CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
color: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
fontSize: 12,
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
};
|
||||
|
||||
/**
|
||||
* SandboxMode: free orbit camera + click-to-interact.
|
||||
* OrbitControls live in RoomScene when mode === 'sandbox'.
|
||||
* This component renders the HUD legend.
|
||||
*/
|
||||
export function SandboxMode() {
|
||||
return (
|
||||
<div style={HUD_STYLE}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4, color: '#0071e3' }}>
|
||||
Mode Sandbox
|
||||
</div>
|
||||
<div>Clic gauche : orbite</div>
|
||||
<div>Clic droit : panoramique</div>
|
||||
<div>Molette : zoom</div>
|
||||
<div style={{ marginTop: 4 }}>Cliquer sur un puzzle pour interagir</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
|
||||
const HUD_STYLE: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
color: '#fff',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
fontSize: 12,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
};
|
||||
|
||||
export function TestMode() {
|
||||
const { engineState, solvedPuzzles, tickEngine } = useSimStore();
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
tickEngine(Date.now());
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, [tickEngine]);
|
||||
|
||||
return (
|
||||
<div style={HUD_STYLE}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 8, color: '#0071e3' }}>
|
||||
Test Mode (10x speed)
|
||||
</div>
|
||||
<div>Phase: {engineState?.phase ?? '—'}</div>
|
||||
<div>Profile: {engineState?.groupProfile ?? 'detecting...'}</div>
|
||||
<div>Solved: {solvedPuzzles.join(', ') || 'none'}</div>
|
||||
<div>Code: {engineState?.codeAssembled ?? '________'}</div>
|
||||
<div>Mood: {engineState?.npcMood ?? '—'}</div>
|
||||
<div>Elapsed: {engineState ? Math.round(engineState.elapsedMs / 1000) : 0}s</div>
|
||||
<div style={{ marginTop: 8, color: 'rgba(255,255,255,0.4)' }}>
|
||||
Click puzzles to solve them
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Text } from '@react-three/drei';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
|
||||
interface Props {
|
||||
position: [number, number, number];
|
||||
}
|
||||
|
||||
const COLORS = ['#ff3b30', '#34c759', '#0071e3', '#ffcc00'];
|
||||
const TARGET = [0, 2, 1, 3]; // melody pattern to reproduce
|
||||
|
||||
function playTone(hz: number, duration = 0.2) {
|
||||
const ctx = new AudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.frequency.value = hz;
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
|
||||
osc.connect(gain).connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + duration);
|
||||
}
|
||||
|
||||
export function P1Sound({ position }: Props) {
|
||||
const { solvePuzzle, mode } = useSimStore();
|
||||
const [pressed, setPressed] = useState<number[]>([]);
|
||||
|
||||
const handleButtonPress = (i: number) => {
|
||||
if (mode === 'demo') return;
|
||||
const newPressed = [...pressed, i];
|
||||
setPressed(newPressed);
|
||||
playTone(220 + i * 110);
|
||||
if (newPressed.length === TARGET.length) {
|
||||
if (newPressed.every((v, j) => v === TARGET[j])) {
|
||||
solvePuzzle('P1_SON');
|
||||
}
|
||||
setPressed([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[0.8, 0.3, 0.8]} />
|
||||
<meshStandardMaterial color="#2c2c2e" />
|
||||
</mesh>
|
||||
{COLORS.map((color, i) => (
|
||||
<mesh
|
||||
key={i}
|
||||
position={[(i % 2) * 0.3 - 0.15, 0.2, Math.floor(i / 2) * 0.3 - 0.15]}
|
||||
onClick={() => handleButtonPress(i)}
|
||||
>
|
||||
<cylinderGeometry args={[0.08, 0.08, 0.06, 16]} />
|
||||
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={0.3} />
|
||||
</mesh>
|
||||
))}
|
||||
<Text position={[0, 0.5, 0]} fontSize={0.1} color="white" anchorX="center">
|
||||
P1 Son
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Text } from '@react-three/drei';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
|
||||
interface Props {
|
||||
position: [number, number, number];
|
||||
}
|
||||
|
||||
// Morse code lookup table
|
||||
const MORSE: Record<string, string> = {
|
||||
A:'.-', B:'-...', C:'-.-.', D:'-..', E:'.', F:'..-.',
|
||||
G:'--.', H:'....', I:'..', J:'.---', K:'-.-', L:'.-..',
|
||||
M:'--', N:'-.', O:'---', P:'.--.', Q:'--.-', R:'.-.',
|
||||
S:'...', T:'-', U:'..-', V:'...-', W:'.--', X:'-..-',
|
||||
Y:'-.--', Z:'--..', '0':'-----', '1':'.----', '2':'..---',
|
||||
'3':'...--', '4':'....-', '5':'.....', '6':'-....', '7':'--...',
|
||||
'8':'---..', '9':'----.',
|
||||
};
|
||||
|
||||
const TARGET_MESSAGE = 'SOS';
|
||||
const targetMorse = TARGET_MESSAGE.split('').map((c) => MORSE[c] ?? '').join(' ');
|
||||
|
||||
function playTone(hz: number, duration = 0.1) {
|
||||
const ctx = new AudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.frequency.value = hz;
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
|
||||
osc.connect(gain).connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + duration);
|
||||
}
|
||||
|
||||
export function P5Morse({ position }: Props) {
|
||||
const { solvePuzzle, mode } = useSimStore();
|
||||
const [active, setActive] = useState(false);
|
||||
const [inputMorse, setInputMorse] = useState('');
|
||||
const pressStartRef = useRef<number>(0);
|
||||
const dotDashThreshold = 200; // ms — below = dot, above = dash
|
||||
|
||||
const handlePress = () => {
|
||||
if (mode === 'demo') return;
|
||||
setActive(true);
|
||||
pressStartRef.current = Date.now();
|
||||
playTone(800, 0.05);
|
||||
};
|
||||
|
||||
const handleRelease = () => {
|
||||
if (mode === 'demo') return;
|
||||
setActive(false);
|
||||
const duration = Date.now() - pressStartRef.current;
|
||||
const symbol = duration < dotDashThreshold ? '.' : '-';
|
||||
setInputMorse((prev) => {
|
||||
const next = prev + symbol;
|
||||
if (next.replace(/ /g, '') === targetMorse.replace(/ /g, '')) {
|
||||
solvePuzzle('P5_MORSE');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
{/* Base */}
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[0.6, 0.1, 0.4]} />
|
||||
<meshStandardMaterial color="#3a3a3a" metalness={0.7} />
|
||||
</mesh>
|
||||
{/* Telegraph key arm */}
|
||||
<mesh
|
||||
position={[0, active ? 0.05 : 0.1, 0]}
|
||||
onPointerDown={handlePress}
|
||||
onPointerUp={handleRelease}
|
||||
>
|
||||
<boxGeometry args={[0.4, 0.04, 0.08]} />
|
||||
<meshStandardMaterial color="#c0c0c0" metalness={0.9} emissive={active ? '#ffffff' : '#000000'} emissiveIntensity={active ? 0.3 : 0} />
|
||||
</mesh>
|
||||
<Text position={[0, 0.3, 0]} fontSize={0.07} color="white" anchorX="center">
|
||||
P5 Morse
|
||||
</Text>
|
||||
<Text position={[0, 0.42, 0]} fontSize={0.05} color="#00ff00" anchorX="center">
|
||||
{inputMorse.slice(-20)}
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Text } from '@react-three/drei';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
|
||||
interface Props {
|
||||
position: [number, number, number];
|
||||
}
|
||||
|
||||
const SYMBOLS = ['α', 'β', 'γ', 'δ', 'σ', 'ω', 'φ', 'ψ', 'χ', 'ρ', 'μ', 'ν'];
|
||||
const CORRECT_ORDER = [0, 3, 6, 1]; // indices of correct symbols in order
|
||||
const SLOT_COUNT = 4;
|
||||
|
||||
export function P6Symbols({ position }: Props) {
|
||||
const { solvePuzzle, mode } = useSimStore();
|
||||
const [slots, setSlots] = useState<(number | null)[]>(Array(SLOT_COUNT).fill(null));
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
|
||||
const handleTileClick = (idx: number) => {
|
||||
if (mode === 'demo') return;
|
||||
setSelected(idx);
|
||||
};
|
||||
|
||||
const handleSlotClick = (slotIdx: number) => {
|
||||
if (mode === 'demo' || selected === null) return;
|
||||
const newSlots = [...slots];
|
||||
newSlots[slotIdx] = selected;
|
||||
setSlots(newSlots);
|
||||
setSelected(null);
|
||||
// Check if complete and correct
|
||||
if (newSlots.every((s) => s !== null)) {
|
||||
const correct = newSlots.every((s, i) => s === CORRECT_ORDER[i]);
|
||||
if (correct) solvePuzzle('P6_SYMBOLES');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
{/* Board */}
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[0.8, 0.05, 0.6]} />
|
||||
<meshStandardMaterial color="#5a3a1a" roughness={0.9} />
|
||||
</mesh>
|
||||
|
||||
{/* Symbol tiles */}
|
||||
{SYMBOLS.slice(0, 8).map((sym, i) => (
|
||||
<mesh
|
||||
key={i}
|
||||
position={[(i % 4) * 0.18 - 0.27, 0.08, Math.floor(i / 4) * 0.18 - 0.09]}
|
||||
onClick={() => handleTileClick(i)}
|
||||
>
|
||||
<boxGeometry args={[0.14, 0.04, 0.14]} />
|
||||
<meshStandardMaterial
|
||||
color={selected === i ? '#0071e3' : '#c8a060'}
|
||||
emissive={selected === i ? '#0071e3' : '#000000'}
|
||||
emissiveIntensity={selected === i ? 0.5 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Symbol text labels */}
|
||||
{SYMBOLS.slice(0, 8).map((sym, i) => (
|
||||
<Text
|
||||
key={`sym-${i}`}
|
||||
position={[(i % 4) * 0.18 - 0.27, 0.12, Math.floor(i / 4) * 0.18 - 0.09]}
|
||||
fontSize={0.08}
|
||||
color="#1a1a1a"
|
||||
anchorX="center"
|
||||
>
|
||||
{sym}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{/* Slots */}
|
||||
{Array.from({ length: SLOT_COUNT }, (_, i) => (
|
||||
<mesh
|
||||
key={`slot-${i}`}
|
||||
position={[i * 0.18 - 0.27, 0.08, 0.22]}
|
||||
onClick={() => handleSlotClick(i)}
|
||||
>
|
||||
<boxGeometry args={[0.14, 0.04, 0.14]} />
|
||||
<meshStandardMaterial
|
||||
color={slots[i] !== null ? '#34c759' : '#3a3a3c'}
|
||||
emissive={slots[i] !== null ? '#34c759' : '#000000'}
|
||||
emissiveIntensity={slots[i] !== null ? 0.3 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
<Text position={[0, 0.4, 0]} fontSize={0.07} color="white" anchorX="center">
|
||||
P6 Symboles
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Text } from '@react-three/drei';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
|
||||
interface Props {
|
||||
position: [number, number, number];
|
||||
}
|
||||
|
||||
const CORRECT_CODE = '12345678'; // assembled from all puzzles
|
||||
const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
|
||||
|
||||
export function P7Coffre({ position }: Props) {
|
||||
const { solvePuzzle, mode } = useSimStore();
|
||||
const [input, setInput] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const handleKey = (digit: string) => {
|
||||
if (mode === 'demo') return;
|
||||
if (!/^\d$/.test(digit)) return; // ignore * and #
|
||||
const newInput = input + digit;
|
||||
setInput(newInput);
|
||||
setError(false);
|
||||
if (newInput.length === 8) {
|
||||
if (newInput === CORRECT_CODE) {
|
||||
solvePuzzle('P7_COFFRE');
|
||||
} else {
|
||||
setError(true);
|
||||
setTimeout(() => { setInput(''); setError(false); }, 800);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
{/* Safe body */}
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[1, 1, 0.8]} />
|
||||
<meshStandardMaterial color="#2c2c2e" metalness={0.5} />
|
||||
</mesh>
|
||||
|
||||
{/* Keypad grid 3x4 */}
|
||||
{KEYS.map((k, i) => (
|
||||
<mesh
|
||||
key={k}
|
||||
position={[(i % 3) * 0.22 - 0.22, 0.6 - Math.floor(i / 3) * 0.18, 0.41]}
|
||||
onClick={() => handleKey(k)}
|
||||
>
|
||||
<boxGeometry args={[0.18, 0.14, 0.04]} />
|
||||
<meshStandardMaterial color={error ? '#ff3b30' : '#3a3a3a'} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Key labels */}
|
||||
{KEYS.map((k, i) => (
|
||||
<Text
|
||||
key={`label-${k}-${i}`}
|
||||
position={[(i % 3) * 0.22 - 0.22, 0.6 - Math.floor(i / 3) * 0.18, 0.44]}
|
||||
fontSize={0.07}
|
||||
color="white"
|
||||
anchorX="center"
|
||||
>
|
||||
{k}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{/* Display */}
|
||||
<Text position={[0, 0.95, 0.41]} fontSize={0.07} color={error ? '#ff3b30' : '#00ff00'} anchorX="center">
|
||||
{input.padEnd(8, '_')}
|
||||
</Text>
|
||||
|
||||
<Text position={[0, 1.2, 0]} fontSize={0.1} color="white" anchorX="center">
|
||||
P7 Coffre
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Html } from '@react-three/drei';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
|
||||
export function NpcBubble() {
|
||||
const npcLastPhrase = useSimStore((s) => s.npcLastPhrase);
|
||||
if (!npcLastPhrase) return null;
|
||||
|
||||
return (
|
||||
<Html position={[0, 1.2, 0]} center>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.85)',
|
||||
color: 'white',
|
||||
padding: '8px 14px',
|
||||
borderRadius: 12,
|
||||
fontSize: 13,
|
||||
maxWidth: 220,
|
||||
textAlign: 'center',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{npcLastPhrase}
|
||||
</div>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Text } from '@react-three/drei';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
position: [number, number, number];
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic puzzle station wrapper: positions content + renders a floating label.
|
||||
*/
|
||||
export function PuzzleStation({ position, label, onClick, children }: Props) {
|
||||
return (
|
||||
<group position={position} {...(onClick ? { onClick } : {})}>
|
||||
{children}
|
||||
<Text position={[0, 0.6, 0]} fontSize={0.1} color="white" anchorX="center">
|
||||
{label}
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Text } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
import { P1Sound } from '../puzzles/P1Sound.js';
|
||||
import { P5Morse } from '../puzzles/P5Morse.js';
|
||||
import { P6Symbols } from '../puzzles/P6Symbols.js';
|
||||
import { P7Coffre } from '../puzzles/P7Coffre.js';
|
||||
|
||||
// Positions arranged around the central table (polar layout)
|
||||
const STATION_POSITIONS: Record<string, [number, number, number]> = {
|
||||
P1_SON: [-4, 0, -2],
|
||||
P2_CIRCUIT: [-4, 0, 2],
|
||||
P3_QR: [ 0, 0, -3.5],
|
||||
P4_RADIO: [ 4, 0, -2],
|
||||
P5_MORSE: [ 4, 0, 2],
|
||||
P6_SYMBOLES: [ 0, 0, 3.5],
|
||||
P7_COFFRE: [ 5, 0, 0],
|
||||
};
|
||||
|
||||
function playTone(hz: number, duration = 0.2) {
|
||||
const ctx = new AudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.frequency.value = hz;
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
|
||||
osc.connect(gain).connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + duration);
|
||||
}
|
||||
|
||||
export function PuzzleStations() {
|
||||
return (
|
||||
<group>
|
||||
<P1Sound position={STATION_POSITIONS['P1_SON']!} />
|
||||
<P2CircuitLed position={STATION_POSITIONS['P2_CIRCUIT']!} />
|
||||
<P3QrTreasure position={STATION_POSITIONS['P3_QR']!} />
|
||||
<P4Radio position={STATION_POSITIONS['P4_RADIO']!} />
|
||||
<P5Morse position={STATION_POSITIONS['P5_MORSE']!} />
|
||||
<P6Symbols position={STATION_POSITIONS['P6_SYMBOLES']!} />
|
||||
<P7Coffre position={STATION_POSITIONS['P7_COFFRE']!} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- P2: Circuit LED ----
|
||||
function P2CircuitLed({ position }: { position: [number, number, number] }) {
|
||||
const { solvePuzzle } = useSimStore();
|
||||
return (
|
||||
<group position={position}>
|
||||
<mesh castShadow onClick={() => solvePuzzle('P2_CIRCUIT')}>
|
||||
<boxGeometry args={[0.9, 0.1, 0.7]} />
|
||||
<meshStandardMaterial color="#1a2a1a" />
|
||||
</mesh>
|
||||
<Text position={[0, 0.3, 0]} fontSize={0.1} color="white" anchorX="center">
|
||||
P2 Circuit
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- P3: QR Treasure ----
|
||||
function P3QrTreasure({ position }: { position: [number, number, number] }) {
|
||||
const { solvePuzzle } = useSimStore();
|
||||
const [scanned, setScanned] = useState(0);
|
||||
|
||||
const handleScan = (i: number) => {
|
||||
if (i !== scanned) return; // must scan in order
|
||||
const next = scanned + 1;
|
||||
setScanned(next);
|
||||
if (next === 6) solvePuzzle('P3_QR');
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<mesh
|
||||
key={i}
|
||||
position={[(i % 3) * 0.3 - 0.3, 1.5, -3.4 + Math.floor(i / 3) * 0.4]}
|
||||
onClick={() => handleScan(i)}
|
||||
>
|
||||
<boxGeometry args={[0.25, 0.25, 0.02]} />
|
||||
<meshStandardMaterial color={i < scanned ? '#34c759' : '#f5f5dc'} />
|
||||
</mesh>
|
||||
))}
|
||||
<Text position={[0, 2.2, -3.4]} fontSize={0.1} color="white" anchorX="center">
|
||||
P3 QR
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- P4: Radio ----
|
||||
function P4Radio({ position }: { position: [number, number, number] }) {
|
||||
const { solvePuzzle, mode } = useSimStore();
|
||||
const dialRef = useRef<THREE.Mesh>(null);
|
||||
const [freq, setFreq] = useState(88.0);
|
||||
const TARGET_FREQ = 93.5;
|
||||
|
||||
const handleDialDrag = (delta: number) => {
|
||||
if (mode === 'demo') return;
|
||||
const newFreq = Math.max(88, Math.min(108, freq + delta * 0.1));
|
||||
const rounded = parseFloat(newFreq.toFixed(1));
|
||||
setFreq(rounded);
|
||||
if (Math.abs(rounded - TARGET_FREQ) < 0.2) {
|
||||
solvePuzzle('P4_RADIO');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
{/* Radio body */}
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[1.2, 0.6, 0.5]} />
|
||||
<meshStandardMaterial color="#8B4513" roughness={0.8} />
|
||||
</mesh>
|
||||
{/* Dial */}
|
||||
<mesh
|
||||
ref={dialRef}
|
||||
position={[0.3, 0.1, 0.26]}
|
||||
onPointerMove={(e) => {
|
||||
if (e.buttons === 1) handleDialDrag(e.movementX);
|
||||
}}
|
||||
>
|
||||
<cylinderGeometry args={[0.12, 0.12, 0.05, 16]} />
|
||||
<meshStandardMaterial color="#c0c0c0" metalness={0.9} roughness={0.1} />
|
||||
</mesh>
|
||||
{/* Frequency display */}
|
||||
<Text position={[-0.1, 0.05, 0.26]} fontSize={0.08} color="#00ff00">
|
||||
{freq.toFixed(1)} MHz
|
||||
</Text>
|
||||
<Text position={[0, 0.5, 0]} fontSize={0.1} color="white" anchorX="center">
|
||||
P4 Radio
|
||||
</Text>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Low-poly room geometry: floor, ceiling, 4 walls.
|
||||
*/
|
||||
export function Room() {
|
||||
const walls = [
|
||||
{ pos: [0, 2, -4] as [number, number, number], rot: [0, 0, 0] as [number, number, number], size: [12, 4] as [number, number] },
|
||||
{ pos: [0, 2, 4] as [number, number, number], rot: [0, Math.PI, 0] as [number, number, number], size: [12, 4] as [number, number] },
|
||||
{ pos: [-6, 2, 0] as [number, number, number], rot: [0, Math.PI / 2, 0] as [number, number, number], size: [8, 4] as [number, number] },
|
||||
{ pos: [6, 2, 0] as [number, number, number], rot: [0, -Math.PI / 2, 0] as [number, number, number], size: [8, 4] as [number, number] },
|
||||
];
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Floor */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]} receiveShadow>
|
||||
<planeGeometry args={[12, 8]} />
|
||||
<meshStandardMaterial color="#1a1a1a" roughness={0.9} metalness={0.1} />
|
||||
</mesh>
|
||||
{/* Ceiling */}
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]} position={[0, 4, 0]}>
|
||||
<planeGeometry args={[12, 8]} />
|
||||
<meshStandardMaterial color="#111111" />
|
||||
</mesh>
|
||||
{/* Walls */}
|
||||
{walls.map((wall, i) => (
|
||||
<mesh key={i} position={wall.pos} rotation={wall.rot} receiveShadow>
|
||||
<planeGeometry args={wall.size} />
|
||||
<meshStandardMaterial color="#1e1e2e" roughness={1} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { OrbitControls, Environment, Sparkles } from '@react-three/drei';
|
||||
import { Suspense } from 'react';
|
||||
import { Room } from './Room.js';
|
||||
import { PuzzleStations } from './PuzzleStations.js';
|
||||
import { RtcPhone } from './RtcPhone.js';
|
||||
import { NpcBubble } from './NpcBubble.js';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
import { DemoCameraController } from '../modes/DemoMode.js';
|
||||
|
||||
export function RoomScene() {
|
||||
const mode = useSimStore((s) => s.mode);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
camera={{ position: [0, 3, 6], fov: 60 }}
|
||||
shadows
|
||||
gl={{ antialias: true }}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
{/* Lighting */}
|
||||
<ambientLight intensity={0.3} />
|
||||
<spotLight
|
||||
position={[0, 5, 0]}
|
||||
angle={0.6}
|
||||
penumbra={0.8}
|
||||
intensity={1.2}
|
||||
castShadow
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
/>
|
||||
|
||||
{/* Atmospheric particles */}
|
||||
<Sparkles count={80} scale={[6, 4, 6]} size={1.5} speed={0.2} opacity={0.3} color="#ffccaa" />
|
||||
|
||||
{/* Room geometry */}
|
||||
<Room />
|
||||
|
||||
{/* Central phone / NPC avatar */}
|
||||
<RtcPhone position={[0, 0, 0]} />
|
||||
|
||||
{/* 7 puzzle stations */}
|
||||
<PuzzleStations />
|
||||
|
||||
{/* NPC speech bubbles */}
|
||||
<NpcBubble />
|
||||
|
||||
{/* Camera control */}
|
||||
{mode === 'sandbox' && <OrbitControls makeDefault />}
|
||||
{mode === 'demo' && <DemoCameraController />}
|
||||
|
||||
<Environment preset="night" />
|
||||
<fog attach="fog" args={['#0a0a0f', 8, 20]} />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useRef } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
import { useSimStore } from '../stores/simStore.js';
|
||||
import { NPC_MOOD_COLORS } from '@zacus/shared';
|
||||
|
||||
interface Props {
|
||||
position: [number, number, number];
|
||||
}
|
||||
|
||||
export function RtcPhone({ position }: Props) {
|
||||
const npcMood = useSimStore((s) => s.npcMood);
|
||||
const glowRef = useRef<THREE.PointLight>(null);
|
||||
|
||||
const moodColor = NPC_MOOD_COLORS[npcMood] ?? '#0071e3';
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!glowRef.current) return;
|
||||
// Pulsing glow effect
|
||||
glowRef.current.intensity = 0.5 + 0.3 * Math.sin(clock.elapsedTime * 2);
|
||||
});
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
{/* Table */}
|
||||
<mesh castShadow position={[0, -0.02, 0]}>
|
||||
<cylinderGeometry args={[0.8, 0.8, 0.04, 32]} />
|
||||
<meshStandardMaterial color="#3a2a1a" roughness={0.8} />
|
||||
</mesh>
|
||||
{/* Phone body */}
|
||||
<mesh castShadow position={[0, 0.15, 0]}>
|
||||
<boxGeometry args={[0.3, 0.25, 0.15]} />
|
||||
<meshStandardMaterial color="#1a1a1a" metalness={0.4} />
|
||||
</mesh>
|
||||
{/* Handset */}
|
||||
<mesh castShadow position={[0, 0.32, 0]} rotation={[0, 0, -0.3]}>
|
||||
<boxGeometry args={[0.2, 0.06, 0.06]} />
|
||||
<meshStandardMaterial color="#111111" />
|
||||
</mesh>
|
||||
{/* Mood glow */}
|
||||
<pointLight ref={glowRef} color={moodColor} distance={2} />
|
||||
<mesh position={[0, 0.1, 0]}>
|
||||
<sphereGeometry args={[0.08, 16, 16]} />
|
||||
<meshStandardMaterial color={moodColor} emissive={moodColor} emissiveIntensity={1} transparent opacity={0.7} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import type { NpcMood, EngineState } from '@zacus/shared';
|
||||
import { parseBlocksDocYaml } from '@zacus/shared';
|
||||
import { ZacusScenarioEngine } from '@zacus/scenario-engine';
|
||||
import { ScenePlayer } from '../lib/scene-player.js';
|
||||
|
||||
export type SimMode = 'demo' | 'sandbox' | 'test';
|
||||
|
||||
// « Zacus dit » emotion → stage NPC mood. Unknown emotions stay neutral.
|
||||
const EMOTION_TO_MOOD: Record<string, NpcMood> = {
|
||||
happy: 'amused',
|
||||
worried: 'worried',
|
||||
urgent: 'worried',
|
||||
mysterious: 'neutral',
|
||||
neutral: 'neutral',
|
||||
};
|
||||
|
||||
interface SimState {
|
||||
mode: SimMode;
|
||||
npcMood: NpcMood;
|
||||
npcLastPhrase: string | null;
|
||||
solvedPuzzles: string[];
|
||||
engineState: EngineState | null;
|
||||
engine: ZacusScenarioEngine | null;
|
||||
/** blocks_studio v2 scene walker (null when running a legacy scenario). */
|
||||
player: ScenePlayer | null;
|
||||
/** Scene currently playing (sceneStart id), v2 mode only. */
|
||||
currentSceneId: string | null;
|
||||
/** Narration lines from the scene walk (hardware actions, waits, …). */
|
||||
simLog: string[];
|
||||
|
||||
setMode: (mode: SimMode) => void;
|
||||
solvePuzzle: (id: string) => void;
|
||||
loadScenario: (yaml: string) => void;
|
||||
tickEngine: (nowMs: number) => void;
|
||||
speakNpc: (phrase: string, rate?: number) => void;
|
||||
}
|
||||
|
||||
export const useSimStore = create<SimState>((set, get) => ({
|
||||
mode: 'sandbox',
|
||||
npcMood: 'neutral',
|
||||
npcLastPhrase: null,
|
||||
solvedPuzzles: [],
|
||||
engineState: null,
|
||||
engine: null,
|
||||
player: null,
|
||||
currentSceneId: null,
|
||||
simLog: [],
|
||||
|
||||
setMode(mode) { set({ mode }); },
|
||||
|
||||
loadScenario(yaml) {
|
||||
// Stop any walk in progress before starting over.
|
||||
get().player?.stop();
|
||||
|
||||
// blocks_studio v2 (the editor's native format since the Runtime 3
|
||||
// toolbox) plays through the ScenePlayer; anything else falls back to
|
||||
// the legacy gameplay engine (old zacus_v3 scenarios).
|
||||
try {
|
||||
const doc = parseBlocksDocYaml(yaml);
|
||||
const log = (line: string) =>
|
||||
set((s) => ({ simLog: [...s.simLog.slice(-99), line] }));
|
||||
const player = new ScenePlayer(doc.nodes, {
|
||||
onSay: (text, opts) => {
|
||||
if (opts.emotion) {
|
||||
set({ npcMood: EMOTION_TO_MOOD[opts.emotion] ?? 'neutral' });
|
||||
}
|
||||
if (opts.voice) log(`🗣 ${opts.voice} (${opts.emotion ?? 'neutre'})`);
|
||||
get().speakNpc(text, opts.rate);
|
||||
},
|
||||
onLog: log,
|
||||
onSceneChange: (sceneId) => {
|
||||
set({ currentSceneId: sceneId });
|
||||
log(`▶ scène « ${sceneId} »`);
|
||||
},
|
||||
onEnd: (reason) => log(`⏹ simulation terminée (${reason})`),
|
||||
});
|
||||
set({ player, engine: null, engineState: null, simLog: [], currentSceneId: null });
|
||||
player.play();
|
||||
return;
|
||||
} catch {
|
||||
// not a v2 document — try the legacy engine below
|
||||
}
|
||||
|
||||
const engine = new ZacusScenarioEngine();
|
||||
engine.load(yaml);
|
||||
engine.start({ targetDuration: 60, mode: '60' });
|
||||
set({ engine, engineState: engine.getState(), player: null });
|
||||
},
|
||||
|
||||
tickEngine(nowMs) {
|
||||
const { engine } = get();
|
||||
if (!engine) return;
|
||||
const state = engine.tick(nowMs);
|
||||
set({ engineState: state, npcMood: state.npcMood });
|
||||
},
|
||||
|
||||
solvePuzzle(id) {
|
||||
const { engine } = get();
|
||||
set((s) => ({ solvedPuzzles: [...s.solvedPuzzles, id] }));
|
||||
if (engine) {
|
||||
const decisions = engine.onEvent({
|
||||
type: 'puzzle_solved',
|
||||
timestamp: Date.now(),
|
||||
data: { puzzle_id: id },
|
||||
});
|
||||
for (const d of decisions) {
|
||||
if (d.action === 'speak') {
|
||||
const phrase = `Excellent ! Puzzle ${id} résolu !`;
|
||||
get().speakNpc(phrase);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
speakNpc(phrase, rate) {
|
||||
set({ npcLastPhrase: phrase });
|
||||
// TTS via Web Speech API — `rate` comes from the « Zacus dit » block.
|
||||
if ('speechSynthesis' in window) {
|
||||
const utt = new SpeechSynthesisUtterance(phrase);
|
||||
utt.lang = 'fr-FR';
|
||||
if (rate && rate > 0) utt.rate = rate;
|
||||
window.speechSynthesis.speak(utt);
|
||||
}
|
||||
// Clear bubble after 5s
|
||||
setTimeout(() => set({ npcLastPhrase: null }), 5000);
|
||||
},
|
||||
}));
|
||||
@@ -4,8 +4,7 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"types": ["@react-three/fiber"]
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
|
||||
@@ -9,7 +9,6 @@ export default defineConfig({
|
||||
output: {
|
||||
manualChunks: {
|
||||
blockly: ['blockly'],
|
||||
three: ['three', '@react-three/fiber', '@react-three/drei'],
|
||||
react: ['react', 'react-dom'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,53 +4,32 @@ import { test, expect } from '@playwright/test';
|
||||
* Atelier golden-path smoke tests.
|
||||
*
|
||||
* The dev server is auto-started by playwright.config.ts (webServer).
|
||||
* Tests assert: layout renders, lazy chunks load, ⌘B collapses the 3D
|
||||
* stage, the Run button surfaces after a debounced editor change.
|
||||
* Tests assert: layout renders, lazy Blockly chunk loads, ⌘B collapses the
|
||||
* stage panel, the Run button surfaces after a debounced editor change.
|
||||
*
|
||||
* The Run-button test uses a dev-only window.__atelierStores hook
|
||||
* (declared in App.tsx behind import.meta.env.DEV) to drive store
|
||||
* state without dragging real Blockly blocks in headless mode.
|
||||
*
|
||||
* NOTE: the 3D simulation stage was removed (phase A of the workbench
|
||||
* refactor). The right panel is now a placeholder ("Banc d'essai");
|
||||
* there is no <canvas>, no mode tabs, and no simStore.
|
||||
*/
|
||||
|
||||
test.describe('atelier — layout & lazy chunks', () => {
|
||||
test('shell renders three drag-resizable panes', async ({ page }) => {
|
||||
test('shell renders editor and stage placeholder panes', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('.blockly-container')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('canvas')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole('button', { name: 'sandbox' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'demo' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'test' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('mode switching updates the active tab', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.blockly-container')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const sandboxTab = page.getByRole('button', { name: 'sandbox' });
|
||||
const testTab = page.getByRole('button', { name: 'test' });
|
||||
|
||||
await expect(sandboxTab).toHaveClass(/atelier-mode-tab--active/);
|
||||
|
||||
await testTab.click();
|
||||
await expect(testTab).toHaveClass(/atelier-mode-tab--active/);
|
||||
await expect(sandboxTab).not.toHaveClass(/atelier-mode-tab--active/);
|
||||
|
||||
await expect(page.getByText('Test Mode (10x speed)')).toBeVisible();
|
||||
await expect(page.getByTestId('stage-pane')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('atelier — ⌘B stage toggle', () => {
|
||||
test('toggleStage collapses and re-expands the 3D stage', async ({ page }) => {
|
||||
test('toggleStage collapses and re-expands the stage panel', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('canvas')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId('stage-pane')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// We assert on the StagePane wrapper, not the <canvas> element.
|
||||
// R3F's <canvas> keeps its intrinsic width (574px) even when the
|
||||
// parent flex-container shrinks to 0 — only a resize event would
|
||||
// tell Three to recompute. The stage-pane div, on the other hand,
|
||||
// follows its parent panel's flex size faithfully.
|
||||
//
|
||||
// We invoke window.__atelierToggleStage directly rather than firing
|
||||
// Cmd+B / Ctrl+B because keyboard modifier handling in headless
|
||||
// Chromium is unreliable across macOS / Linux CI runners. The keyboard
|
||||
@@ -80,7 +59,7 @@ test.describe('atelier — ⌘B stage toggle', () => {
|
||||
test.describe('atelier — Run button stale flow', () => {
|
||||
test('Run button surfaces after a debounced editor change', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('canvas')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('.blockly-container')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Initially neither badge nor button is rendered.
|
||||
await expect(page.getByRole('button', { name: /Run|stale/ })).toHaveCount(0);
|
||||
|
||||
Generated
+1
-539
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user