refactor(atelier): remove 3D simulation (StagePane/scene/puzzles/modes/simStore, three deps)

This commit is contained in:
2026-06-13 23:28:53 +02:00
parent 808d8bca2a
commit f6bb3d3a41
23 changed files with 20 additions and 1629 deletions
-4
View File
@@ -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",
-2
View File
@@ -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);
},
}));
+1 -2
View File
@@ -4,8 +4,7 @@
"rootDir": "src",
"outDir": "dist",
"composite": true,
"noEmit": false,
"types": ["@react-three/fiber"]
"noEmit": false
},
"include": ["src"],
"references": [
-1
View File
@@ -9,7 +9,6 @@ export default defineConfig({
output: {
manualChunks: {
blockly: ['blockly'],
three: ['three', '@react-three/fiber', '@react-three/drei'],
react: ['react', 'react-dom'],
},
},
+11 -32
View File
@@ -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);
+1 -539
View File
File diff suppressed because it is too large Load Diff