feat: add SynthQuest game mode with World 1 (Waves) — 8 puzzle levels

Gamified synth learning inspired by Turing Complete. Progressive puzzle
system teaches oscillators, waveforms, frequency, and mixing through
hands-on module patching. Includes world map, level progression with
3-star rating, target audio playback, solution validation, and
localStorage persistence. Sandbox mode still accessible via button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 02:04:26 +01:00
parent d0755413f3
commit 08206e996e
10 changed files with 1500 additions and 3 deletions

View File

@@ -9,7 +9,7 @@ import ModulePalette from './components/ModulePalette.jsx';
import PresetModal from './components/PresetModal.jsx';
import { CHIPTUNE_PRESET } from './presets/chiptune.js';
export default function App() {
export default function App({ onSwitchToGame }) {
const [, forceUpdate] = useState(0);
const containerRef = useRef(null);
const portPositions = useRef({});
@@ -205,6 +205,11 @@ export default function App() {
<div className="app">
{/* Toolbar */}
<div className="toolbar">
{onSwitchToGame && (
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
🎮 Game
</button>
)}
<span className="toolbar-title">Reaktor</span>
<div className="toolbar-sep" />
<button className={`toolbar-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>

54
src/game/GameApp.jsx Normal file
View File

@@ -0,0 +1,54 @@
import React, { useState, useCallback } from 'react';
import WorldMap from './WorldMap.jsx';
import PuzzleView from './PuzzleView.jsx';
import { WORLD_1 } from './levels/world1.js';
export default function GameApp({ onSwitchToSandbox }) {
const [view, setView] = useState('map'); // 'map' | 'puzzle'
const [currentLevel, setCurrentLevel] = useState(null);
const [currentLevelIndex, setCurrentLevelIndex] = useState(0);
const worldLevels = WORLD_1.levels;
const handleSelectLevel = useCallback((level) => {
const idx = worldLevels.findIndex(l => l.id === level.id);
setCurrentLevel(level);
setCurrentLevelIndex(idx);
setView('puzzle');
}, [worldLevels]);
const handleBack = useCallback(() => {
setView('map');
setCurrentLevel(null);
}, []);
const handleNextLevel = useCallback(() => {
const nextIdx = currentLevelIndex + 1;
if (nextIdx < worldLevels.length) {
setCurrentLevel(worldLevels[nextIdx]);
setCurrentLevelIndex(nextIdx);
} else {
setView('map');
}
}, [currentLevelIndex, worldLevels]);
if (view === 'puzzle' && currentLevel) {
return (
<PuzzleView
key={currentLevel.id}
level={currentLevel}
levelIndex={currentLevelIndex}
worldLevels={worldLevels}
onBack={handleBack}
onNextLevel={handleNextLevel}
/>
);
}
return (
<WorldMap
onSelectLevel={handleSelectLevel}
onSandbox={onSwitchToSandbox}
/>
);
}

View File

@@ -0,0 +1,66 @@
import React, { useEffect, useState } from 'react';
export default function LevelComplete({ stars, checks, levelTitle, onNext, onRetry, onMap, isLastLevel }) {
const [showStars, setShowStars] = useState(0);
useEffect(() => {
// Animate stars appearing one by one
const timers = [];
for (let i = 1; i <= stars; i++) {
timers.push(setTimeout(() => setShowStars(i), i * 400));
}
return () => timers.forEach(clearTimeout);
}, [stars]);
const messages = [
'', // 0 stars
'Has dado el primer paso...',
'Buen trabajo — casi perfecto.',
'Ejecucion impecable.',
];
return (
<div className="gm-complete-overlay">
<div className="gm-complete-card">
<h2 className="gm-complete-title">
{stars >= 1 ? 'Nivel Completado' : 'Sigue Intentando'}
</h2>
<p className="gm-complete-level">{levelTitle}</p>
{/* Stars */}
<div className="gm-complete-stars">
{[1, 2, 3].map(i => (
<span
key={i}
className={`gm-big-star ${i <= showStars ? 'earned' : 'empty'}`}
>
</span>
))}
</div>
<p className="gm-complete-msg">{messages[stars] || ''}</p>
{/* Checks */}
<div className="gm-checks">
{checks.map((check, i) => (
<div key={i} className={`gm-check ${check.passed ? 'passed' : 'failed'}`}>
<span className="gm-check-icon">{check.passed ? '✓' : '✗'}</span>
<span className="gm-check-name">{check.name}</span>
<span className="gm-check-star">{'★'.repeat(check.star)}</span>
</div>
))}
</div>
{/* Actions */}
<div className="gm-complete-actions">
<button className="gm-btn secondary" onClick={onMap}>Mapa</button>
<button className="gm-btn secondary" onClick={onRetry}>Reintentar</button>
{stars >= 1 && !isLastLevel && (
<button className="gm-btn primary" onClick={onNext}>Siguiente </button>
)}
</div>
</div>
</div>
);
}

379
src/game/PuzzleView.jsx Normal file
View File

@@ -0,0 +1,379 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { state, subscribe, addModule, emit, addConnection, removeModule, updateModulePosition, deserialize } from '../engine/state.js';
import { startAudio, stopAudio, connectWire, rebuildGraph } from '../engine/audioEngine.js';
import { getModuleDef } from '../engine/moduleRegistry.js';
import ModuleNode from '../components/ModuleNode.jsx';
import WireLayer from '../components/WireLayer.jsx';
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
import LevelComplete from './LevelComplete.jsx';
import { completeLevel } from './gameState.js';
export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onNextLevel }) {
const [, forceUpdate] = useState(0);
const containerRef = useRef(null);
const portPositions = useRef({});
const [tempWire, setTempWire] = useState(null);
const connectingRef = useRef(null);
const [showConcept, setShowConcept] = useState(true);
const [result, setResult] = useState(null); // { stars, checks }
const [targetPlaying, setTargetPlaying] = useState(false);
// Subscribe to state changes
useEffect(() => {
const unsub = subscribe(() => forceUpdate(n => n + 1));
return unsub;
}, []);
// Load level on mount
useEffect(() => {
loadLevel();
return () => {
stopAudio();
stopTarget();
};
}, [level.id]);
const loadLevel = useCallback(() => {
// Clear state and load preplaced modules
const data = {
modules: (level.preplacedModules || []).map(m => ({
id: m.id,
type: m.type,
x: m.x,
y: m.y,
params: { ...m.params },
})),
connections: [],
camera: { camX: 0, camY: 0, zoom: 1 },
};
deserialize(data);
setResult(null);
setShowConcept(true);
if (state.isRunning) stopAudio();
}, [level]);
// Port position reporting
const handlePortPosition = useCallback((moduleId, portName, direction, el) => {
const key = `${moduleId}-${portName}-${direction}`;
portPositions.current[key] = el;
}, []);
// Start connecting wire
const handleStartConnect = useCallback((info) => {
connectingRef.current = info;
const containerRect = containerRef.current.getBoundingClientRect();
setTempWire({
portType: info.portType,
startX: info.startX - containerRect.left,
startY: info.startY - containerRect.top,
endX: info.startX - containerRect.left,
endY: info.startY - containerRect.top,
});
}, []);
const findPortAtPoint = (x, y) => {
for (const [dx, dy] of [[0,0],[-8,0],[8,0],[0,-8],[0,8],[-6,-6],[6,-6],[-6,6],[6,6]]) {
const hit = document.elementFromPoint(x + dx, y + dy);
if (hit && hit.classList.contains('port-dot') && hit.dataset.moduleId) {
return hit;
}
}
return null;
};
const handlePointerDown = useCallback((e) => {
if (e.button === 1 || (e.button === 0 && e.altKey) || e.button === 2) {
state.panning = true;
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault();
} else if (e.button === 0 && !connectingRef.current) {
state.selectedModuleId = null;
emit();
}
}, []);
const handlePointerMove = useCallback((e) => {
if (state.panning && state.panStart) {
state.camX = e.clientX - state.panStart.x;
state.camY = e.clientY - state.panStart.y;
emit();
return;
}
if (state.dragging) {
const newX = e.clientX / state.zoom - state.dragging.offsetX;
const newY = e.clientY / state.zoom - state.dragging.offsetY;
updateModulePosition(state.dragging.moduleId, newX, newY);
return;
}
if (connectingRef.current && containerRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
setTempWire(prev => prev ? {
...prev,
endX: e.clientX - containerRect.left,
endY: e.clientY - containerRect.top,
} : null);
}
}, []);
const handlePointerUp = useCallback((e) => {
if (state.panning) {
state.panning = false;
state.panStart = null;
}
if (state.dragging) {
state.dragging = null;
emit();
}
if (connectingRef.current) {
const portEl = findPortAtPoint(e.clientX, e.clientY);
if (portEl) finishConnection(portEl);
connectingRef.current = null;
setTempWire(null);
}
}, []);
const finishConnection = (portEl) => {
const from = connectingRef.current;
if (!from) return;
const targetModuleId = parseInt(portEl.dataset.moduleId);
const targetPort = portEl.dataset.portName;
const targetDirection = portEl.dataset.portDirection;
if (!targetModuleId || !targetPort || !targetDirection) return;
if (targetModuleId === from.moduleId && targetPort === from.port) return;
let fromMod, fromPort, toMod, toPort;
if (from.direction === 'output' && targetDirection === 'input') {
fromMod = from.moduleId; fromPort = from.port;
toMod = targetModuleId; toPort = targetPort;
} else if (from.direction === 'input' && targetDirection === 'output') {
fromMod = targetModuleId; fromPort = targetPort;
toMod = from.moduleId; toPort = from.port;
} else return;
const connId = addConnection(fromMod, fromPort, toMod, toPort);
if (connId && state.isRunning) {
const conn = state.connections.find(c => c.id === connId);
if (conn) connectWire(conn);
}
};
const handleWheel = useCallback((e) => {
e.preventDefault();
const delta = -e.deltaY * 0.001;
state.zoom = Math.max(0.3, Math.min(3, state.zoom + delta));
emit();
}, []);
const handleContextMenu = useCallback((e) => e.preventDefault(), []);
// Add module from palette
const handleAddModule = (type) => {
const x = (-state.camX + 250) / state.zoom + Math.random() * 30;
const y = (-state.camY + 150) / state.zoom + Math.random() * 30;
addModule(type, x, y);
if (state.isRunning) rebuildGraph();
};
// Toggle player audio
const handleToggleAudio = async () => {
if (state.isRunning) {
stopAudio();
} else {
await startAudio();
}
emit();
};
// Play target sound
const handlePlayTarget = async () => {
if (isTargetPlaying()) {
stopTarget();
setTargetPlaying(false);
} else {
setTargetPlaying(true);
await playTarget(level.target);
// Auto-update when target stops
setTimeout(() => setTargetPlaying(false), (level.target.duration || 2) * 1000 + 100);
}
};
// Validate solution
const handleCheck = () => {
const mods = state.modules;
const conns = state.connections;
const checks = level.checks.map(check => ({
...check,
passed: check.test(mods, conns),
}));
// Stars: sequential — need all previous stars to earn next
let stars = 0;
for (const check of checks) {
if (check.passed) stars = check.star;
else break;
}
setResult({ stars, checks });
if (stars >= 1) {
completeLevel(level.id, stars);
}
};
const isLastLevel = levelIndex >= worldLevels.length - 1;
return (
<div className="gm-puzzle">
{/* Top bar */}
<div className="gm-puzzle-bar">
<button className="gm-btn icon" onClick={onBack}> Mapa</button>
<div className="gm-puzzle-title">
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
<span className="gm-puzzle-name">{level.title}</span>
</div>
<div className="gm-puzzle-actions">
<button
className={`gm-btn ${targetPlaying ? 'active' : 'target'}`}
onClick={handlePlayTarget}
>
{targetPlaying ? '⏹ Parar' : '🎯 Objetivo'}
</button>
<button
className={`gm-btn ${state.isRunning ? 'active' : ''}`}
onClick={handleToggleAudio}
>
{state.isRunning ? '⏹ Parar' : '▶ Mi Sonido'}
</button>
<button className="gm-btn check" onClick={handleCheck}>
Comprobar
</button>
</div>
</div>
<div className="gm-puzzle-content">
{/* Left sidebar: concept + module palette */}
<div className="gm-puzzle-sidebar">
{/* Concept panel */}
<div className="gm-concept-panel">
<div className="gm-concept-header" onClick={() => setShowConcept(!showConcept)}>
<span>💡 Concepto</span>
<span>{showConcept ? '▼' : '▶'}</span>
</div>
{showConcept && (
<div className="gm-concept-body">
<p className="gm-concept-desc">{level.description}</p>
<div className="gm-concept-tip">
<strong>Pista:</strong> {level.concept}
</div>
</div>
)}
</div>
{/* Objectives */}
<div className="gm-objectives">
<div className="gm-obj-title">Objetivos</div>
{level.checks.map((check, i) => {
const passed = result?.checks?.[i]?.passed;
return (
<div key={i} className={`gm-obj ${passed === true ? 'passed' : passed === false ? 'failed' : ''}`}>
<span className="gm-obj-star">{'★'.repeat(check.star)}</span>
<span className="gm-obj-name">{check.desc}</span>
{passed === true && <span className="gm-obj-check"></span>}
{passed === false && <span className="gm-obj-x"></span>}
</div>
);
})}
</div>
{/* Module palette for this level */}
{level.availableModules.length > 0 && (
<div className="gm-module-palette">
<div className="gm-palette-title">Modulos Disponibles</div>
{level.availableModules.map(type => {
const def = getModuleDef(type);
if (!def) return null;
return (
<div
key={type}
className="gm-palette-item"
onClick={() => handleAddModule(type)}
>
<span className="gm-palette-icon">{def.icon}</span>
<span className="gm-palette-name">{def.name}</span>
<span className="gm-palette-add">+</span>
</div>
);
})}
</div>
)}
{/* Reset button */}
<button className="gm-btn danger" onClick={loadLevel} style={{ marginTop: 'auto' }}>
Reiniciar Nivel
</button>
</div>
{/* Main canvas */}
<div className="gm-puzzle-canvas-wrap">
<div
ref={containerRef}
className={`node-canvas ${state.panning ? 'grabbing' : ''} ${connectingRef.current ? 'connecting' : ''}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onWheel={handleWheel}
onContextMenu={handleContextMenu}
>
{/* Grid */}
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 0 }}>
<defs>
<pattern id="puzzle-grid" width={20 * state.zoom} height={20 * state.zoom}
patternUnits="userSpaceOnUse"
x={state.camX % (20 * state.zoom)} y={state.camY % (20 * state.zoom)}>
<circle cx={1} cy={1} r={0.8} fill="#1a1a30" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#puzzle-grid)" />
</svg>
{/* Wires */}
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
{/* Modules */}
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
{state.modules.map(mod => (
<ModuleNode
key={mod.id}
mod={mod}
zoom={state.zoom}
onStartConnect={handleStartConnect}
onPortPosition={handlePortPosition}
/>
))}
</div>
</div>
{/* Canvas hints */}
{state.modules.length > 0 && state.connections.length === 0 && (
<div className="gm-canvas-hint">
Arrastra de un puerto (circulo) a otro para conectar modulos
</div>
)}
</div>
</div>
{/* Level complete overlay */}
{result && result.stars >= 1 && (
<LevelComplete
stars={result.stars}
checks={result.checks}
levelTitle={level.title}
isLastLevel={isLastLevel}
onRetry={loadLevel}
onMap={onBack}
onNext={onNextLevel}
/>
)}
</div>
);
}

111
src/game/WorldMap.jsx Normal file
View File

@@ -0,0 +1,111 @@
import React from 'react';
import { WORLD_1 } from './levels/world1.js';
import { getLevelProgress, isLevelUnlocked } from './gameState.js';
const worlds = [WORLD_1];
function Stars({ count, max = 3 }) {
return (
<span className="gm-stars">
{Array.from({ length: max }, (_, i) => (
<span key={i} className={i < count ? 'star filled' : 'star empty'}></span>
))}
</span>
);
}
export default function WorldMap({ onSelectLevel, onSandbox }) {
const world = WORLD_1;
const totalStars = world.levels.reduce((s, l) => {
const p = getLevelProgress(l.id);
return s + (p?.stars || 0);
}, 0);
const maxStars = world.levels.length * 3;
return (
<div className="gm-worldmap">
{/* Header */}
<div className="gm-header">
<div className="gm-logo">
<span className="gm-logo-icon">~</span>
<div>
<h1 className="gm-title">SynthQuest</h1>
<p className="gm-tagline">Aprende sintesis modular resolviendo puzzles</p>
</div>
</div>
<div className="gm-header-right">
<div className="gm-total-stars">
<span className="star filled"></span> {totalStars}/{maxStars}
</div>
<button className="gm-sandbox-btn" onClick={onSandbox}>
🎛 Sandbox
</button>
</div>
</div>
{/* World section */}
<div className="gm-world-section">
<div className="gm-world-header">
<span className="gm-world-icon" style={{ color: world.color }}>{world.icon}</span>
<div>
<h2 className="gm-world-name">Mundo 1: {world.name}</h2>
<p className="gm-world-sub">{world.subtitle}</p>
</div>
</div>
{/* Level grid */}
<div className="gm-level-grid">
{world.levels.map((level, idx) => {
const progress = getLevelProgress(level.id);
const unlocked = isLevelUnlocked(level.id, world.levels);
const stars = progress?.stars || 0;
const isBoss = idx === world.levels.length - 1;
return (
<div
key={level.id}
className={`gm-level-card ${unlocked ? 'unlocked' : 'locked'} ${isBoss ? 'boss' : ''} ${stars === 3 ? 'perfect' : ''}`}
onClick={() => unlocked && onSelectLevel(level)}
>
<div className="gm-level-number">{idx + 1}</div>
<div className="gm-level-info">
<h3 className="gm-level-title">{level.title}</h3>
<p className="gm-level-subtitle">{level.subtitle}</p>
</div>
{unlocked ? (
<Stars count={stars} />
) : (
<span className="gm-lock">🔒</span>
)}
{!unlocked && <div className="gm-lock-overlay" />}
</div>
);
})}
</div>
</div>
{/* Future worlds teaser */}
<div className="gm-world-section gm-locked-world">
<div className="gm-world-header">
<span className="gm-world-icon" style={{ color: '#666' }}></span>
<div>
<h2 className="gm-world-name" style={{ color: '#666' }}>Mundo 2: Filtros</h2>
<p className="gm-world-sub">Proximamente... Consigue {Math.ceil(maxStars * 0.6)} estrellas para desbloquear</p>
</div>
<span className="gm-lock" style={{ fontSize: 24 }}>🔒</span>
</div>
</div>
<div className="gm-world-section gm-locked-world">
<div className="gm-world-header">
<span className="gm-world-icon" style={{ color: '#666' }}></span>
<div>
<h2 className="gm-world-name" style={{ color: '#666' }}>Mundo 3: Envelopes</h2>
<p className="gm-world-sub">Proximamente...</p>
</div>
<span className="gm-lock" style={{ fontSize: 24 }}>🔒</span>
</div>
</div>
</div>
);
}

64
src/game/gameState.js Normal file
View File

@@ -0,0 +1,64 @@
/**
* gameState.js — Game progress persistence
* Tracks completed levels, stars earned, and unlocks
*/
const STORAGE_KEY = 'synthquest-progress';
const defaultProgress = {
currentWorld: 'w1',
completedLevels: {}, // { levelId: { stars: 3, bestTime: 12.5 } }
unlockedWorlds: ['w1'],
totalStars: 0,
};
let _progress = null;
export function loadProgress() {
if (_progress) return _progress;
try {
const raw = localStorage.getItem(STORAGE_KEY);
_progress = raw ? { ...defaultProgress, ...JSON.parse(raw) } : { ...defaultProgress };
} catch {
_progress = { ...defaultProgress };
}
return _progress;
}
export function saveProgress() {
if (!_progress) return;
_progress.totalStars = Object.values(_progress.completedLevels)
.reduce((sum, l) => sum + (l.stars || 0), 0);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(_progress));
} catch {}
}
export function completeLevel(levelId, stars) {
const p = loadProgress();
const existing = p.completedLevels[levelId];
if (!existing || stars > existing.stars) {
p.completedLevels[levelId] = { stars, completedAt: Date.now() };
}
saveProgress();
}
export function getLevelProgress(levelId) {
const p = loadProgress();
return p.completedLevels[levelId] || null;
}
export function isLevelUnlocked(levelId, worldLevels) {
const p = loadProgress();
// First level is always unlocked
const idx = worldLevels.findIndex(l => l.id === levelId);
if (idx === 0) return true;
// Previous level must have at least 1 star
const prevId = worldLevels[idx - 1]?.id;
return prevId && p.completedLevels[prevId]?.stars >= 1;
}
export function resetProgress() {
_progress = { ...defaultProgress };
saveProgress();
}

481
src/game/levels/world1.js Normal file
View File

@@ -0,0 +1,481 @@
/**
* World 1 — "Ondas" (Waves)
*
* Teaches: oscillators, waveforms, frequency, mixing
* 8 levels, progressive difficulty
*/
export const WORLD_1 = {
id: 'w1',
name: 'Ondas',
subtitle: 'Los bloques fundamentales del sonido',
icon: '~',
color: '#00e5ff',
levels: [
// ─────────────── LEVEL 1.1 ───────────────
{
id: 'w1-1',
title: 'Tu Primer Sonido',
subtitle: 'Conecta y escucha',
description: 'Todo sonido en un sintetizador empieza con un oscilador. Un oscilador genera una onda que vibra a una frecuencia determinada. Conecta el oscilador a la salida para escuchar tu primer sonido.',
concept: 'Un oscilador genera una onda. La salida (Output) envía el sonido a tus altavoces. Conecta la salida del oscilador al input de la salida arrastrando de un puerto a otro.',
availableModules: [], // No new modules to add, just connect preplaced
preplacedModules: [
{ id: 1, type: 'oscillator', x: 100, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
{ id: 2, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
],
target: {
// Simple sine at 440Hz
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
],
duration: 2,
},
checks: [
{
star: 1,
name: 'Sonido conectado',
desc: 'Conecta el oscilador a la salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const out = mods.find(m => m.type === 'output');
if (!osc || !out) return false;
return conns.some(c =>
c.from.moduleId === osc.id && c.from.port === 'out' &&
c.to.moduleId === out.id
);
},
},
{
star: 2,
name: 'Canal izquierdo',
desc: 'Conecta al canal izquierdo (Left)',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const out = mods.find(m => m.type === 'output');
if (!osc || !out) return false;
return conns.some(c =>
c.from.moduleId === osc.id && c.from.port === 'out' &&
c.to.moduleId === out.id && c.to.port === 'left'
);
},
},
{
star: 3,
name: 'Estéreo completo',
desc: 'Conecta también al canal derecho (Right)',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const out = mods.find(m => m.type === 'output');
if (!osc || !out) return false;
const hasLeft = conns.some(c =>
c.from.moduleId === osc.id && c.to.moduleId === out.id && c.to.port === 'left'
);
const hasRight = conns.some(c =>
c.from.moduleId === osc.id && c.to.moduleId === out.id && c.to.port === 'right'
);
return hasLeft && hasRight;
},
},
],
},
// ─────────────── LEVEL 1.2 ───────────────
{
id: 'w1-2',
title: 'La Nota La',
subtitle: 'Afinación: 440 Hz',
description: 'La nota La central (A4) vibra a exactamente 440 Hz. Es la referencia universal para afinar instrumentos. Coloca un oscilador, ajústalo a 440 Hz y conéctalo a la salida.',
concept: 'La frecuencia se mide en Hertz (Hz) — cuántas veces vibra la onda por segundo. 440 Hz = nota La. Usa el knob de frecuencia para ajustar.',
availableModules: ['oscillator'],
preplacedModules: [
{ id: 1, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
],
duration: 2,
},
checks: [
{
star: 1,
name: 'Oscilador conectado',
desc: 'Coloca un oscilador y conéctalo a la salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const out = mods.find(m => m.type === 'output');
if (!osc || !out) return false;
return conns.some(c =>
c.from.moduleId === osc.id && c.to.moduleId === out.id
);
},
},
{
star: 2,
name: 'Frecuencia cercana',
desc: 'Ajusta la frecuencia cerca de 440 Hz (±50 Hz)',
test: (mods) => {
const osc = mods.find(m => m.type === 'oscillator');
if (!osc) return false;
const f = osc.params.frequency ?? 440;
return Math.abs(f - 440) <= 50;
},
},
{
star: 3,
name: 'Afinación perfecta',
desc: 'Ajusta la frecuencia exacta a 440 Hz (±10 Hz)',
test: (mods) => {
const osc = mods.find(m => m.type === 'oscillator');
if (!osc) return false;
const f = osc.params.frequency ?? 440;
return Math.abs(f - 440) <= 10;
},
},
],
},
// ─────────────── LEVEL 1.3 ───────────────
{
id: 'w1-3',
title: 'Onda Cuadrada',
subtitle: 'El sonido 8-bit',
description: 'La onda cuadrada es EL sonido de los videojuegos retro. El chip de sonido del NES y el Game Boy usaban ondas cuadradas. Escucha el objetivo y replica ese timbre.',
concept: 'Cada forma de onda tiene un timbre diferente. La onda cuadrada suena brillante y "digital" porque contiene solo armónicos impares. Cambia el selector de onda (Wave) a "square".',
availableModules: ['oscillator'],
preplacedModules: [
{ id: 1, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'square', frequency: 440 } },
],
duration: 2,
},
checks: [
{
star: 1,
name: 'Sonido conectado',
desc: 'Oscilador conectado a la salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const out = mods.find(m => m.type === 'output');
return osc && out && conns.some(c =>
c.from.moduleId === osc.id && c.to.moduleId === out.id
);
},
},
{
star: 2,
name: 'Onda cuadrada',
desc: 'Cambia la forma de onda a square',
test: (mods) => {
const osc = mods.find(m => m.type === 'oscillator');
return osc && (osc.params.waveform === 'square');
},
},
{
star: 3,
name: 'Match perfecto',
desc: 'Cuadrada a 440 Hz (±10 Hz)',
test: (mods) => {
const osc = mods.find(m => m.type === 'oscillator');
if (!osc) return false;
return osc.params.waveform === 'square' && Math.abs((osc.params.frequency ?? 440) - 440) <= 10;
},
},
],
},
// ─────────────── LEVEL 1.4 ───────────────
{
id: 'w1-4',
title: 'Diente de Sierra',
subtitle: 'Rica en armónicos',
description: 'La onda diente de sierra (sawtooth) contiene TODOS los armónicos, lo que le da un sonido brillante y rico. Es la base de muchos sonidos de sintetizador clásicos, desde pads hasta leads.',
concept: 'Sawtooth = todos los armónicos. Sine = solo la fundamental. Square = armónicos impares. Triangle = armónicos impares atenuados. Cada forma de onda tiene un carácter único.',
availableModules: ['oscillator'],
preplacedModules: [
{ id: 1, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 440 } },
],
duration: 2,
},
checks: [
{
star: 1,
name: 'Sonido conectado',
desc: 'Oscilador conectado a la salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const out = mods.find(m => m.type === 'output');
return osc && out && conns.some(c =>
c.from.moduleId === osc.id && c.to.moduleId === out.id
);
},
},
{
star: 2,
name: 'Diente de sierra',
desc: 'Cambia la onda a sawtooth',
test: (mods) => {
const osc = mods.find(m => m.type === 'oscillator');
return osc && osc.params.waveform === 'sawtooth';
},
},
{
star: 3,
name: 'Match perfecto',
desc: 'Sawtooth a 440 Hz (±10 Hz)',
test: (mods) => {
const osc = mods.find(m => m.type === 'oscillator');
if (!osc) return false;
return osc.params.waveform === 'sawtooth' && Math.abs((osc.params.frequency ?? 440) - 440) <= 10;
},
},
],
},
// ─────────────── LEVEL 1.5 ───────────────
{
id: 'w1-5',
title: 'La Octava',
subtitle: 'Doble o mitad',
description: 'Una octava es la relación más fundamental en la música. Cuando doblas la frecuencia, subes una octava. Cuando la divides por dos, bajas una octava. Si La4 es 440 Hz, La3 es 220 Hz.',
concept: 'Octava arriba = frecuencia × 2. Octava abajo = frecuencia ÷ 2. Ejemplo: A4=440Hz, A3=220Hz, A5=880Hz. Ajusta el knob a 220 Hz para bajar una octava.',
availableModules: ['oscillator'],
preplacedModules: [
{ id: 1, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220 } },
],
duration: 2,
},
checks: [
{
star: 1,
name: 'Sonido conectado',
desc: 'Oscilador conectado a la salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const out = mods.find(m => m.type === 'output');
return osc && out && conns.some(c =>
c.from.moduleId === osc.id && c.to.moduleId === out.id
);
},
},
{
star: 2,
name: 'Octava baja',
desc: 'Frecuencia cercana a 220 Hz (±30 Hz)',
test: (mods) => {
const osc = mods.find(m => m.type === 'oscillator');
if (!osc) return false;
return Math.abs((osc.params.frequency ?? 440) - 220) <= 30;
},
},
{
star: 3,
name: 'Afinación perfecta',
desc: 'Exactamente 220 Hz (±10 Hz)',
test: (mods) => {
const osc = mods.find(m => m.type === 'oscillator');
if (!osc) return false;
return Math.abs((osc.params.frequency ?? 440) - 220) <= 10;
},
},
],
},
// ─────────────── LEVEL 1.6 ───────────────
{
id: 'w1-6',
title: 'Dos Voces',
subtitle: 'La quinta perfecta',
description: 'Mezclar dos frecuencias crea armonía. La relación 3:2 entre dos notas es la "quinta perfecta" — el intervalo más consonante después de la octava. Mezcla 440 Hz con 660 Hz.',
concept: 'Un Mixer combina varias señales de audio en una sola. Conecta varios osciladores a las entradas del mixer, y la salida del mixer al output. 440×1.5 = 660 Hz (quinta perfecta).',
availableModules: ['oscillator', 'mixer'],
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 140, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 660 } },
],
duration: 2.5,
},
checks: [
{
star: 1,
name: 'Dos osciladores',
desc: 'Coloca 2 osciladores y un mixer, conectados a la salida',
test: (mods, conns) => {
const oscs = mods.filter(m => m.type === 'oscillator');
const mixer = mods.find(m => m.type === 'mixer');
const out = mods.find(m => m.type === 'output');
if (oscs.length < 2 || !mixer || !out) return false;
// Check mixer → output
const mixerToOut = conns.some(c => c.from.moduleId === mixer.id && c.to.moduleId === out.id);
// Check at least one osc → mixer
const oscToMixer = oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === mixer.id));
return mixerToOut && oscToMixer;
},
},
{
star: 2,
name: 'Ambos conectados',
desc: 'Ambos osciladores al mixer, mixer a la salida',
test: (mods, conns) => {
const oscs = mods.filter(m => m.type === 'oscillator');
const mixer = mods.find(m => m.type === 'mixer');
const out = mods.find(m => m.type === 'output');
if (oscs.length < 2 || !mixer || !out) return false;
const mixerToOut = conns.some(c => c.from.moduleId === mixer.id && c.to.moduleId === out.id);
const bothToMixer = oscs.every(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === mixer.id));
return mixerToOut && bothToMixer;
},
},
{
star: 3,
name: 'Quinta perfecta',
desc: 'Frecuencias a ~440 Hz y ~660 Hz (±20 Hz)',
test: (mods, conns) => {
const oscs = mods.filter(m => m.type === 'oscillator');
if (oscs.length < 2) return false;
const freqs = oscs.map(o => o.params.frequency ?? 440).sort((a, b) => a - b);
return Math.abs(freqs[0] - 440) <= 20 && Math.abs(freqs[1] - 660) <= 20;
},
},
],
},
// ─────────────── LEVEL 1.7 ───────────────
{
id: 'w1-7',
title: 'El Unísono Gordo',
subtitle: 'Detune = grosor',
description: 'Los sintetizadores analógicos clásicos nunca estaban perfectamente afinados, y eso les daba un sonido "gordo". Desafinar ligeramente dos osciladores crea un efecto de coro natural. Usa dos sierras con un ligero detune.',
concept: 'Detune = desafinación en "cents" (centésimas de semitono). Un detune de +7 a +12 cents crea un unísono gordo sin que suene desafinado. Usa dos osciladores sawtooth a la misma frecuencia pero con detune diferente.',
availableModules: ['oscillator', 'mixer'],
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 140, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 440, detune: -7 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 440, detune: 7 } },
],
duration: 2.5,
},
checks: [
{
star: 1,
name: 'Dos sierras mezcladas',
desc: 'Dos osciladores sawtooth conectados al mixer y a la salida',
test: (mods, conns) => {
const oscs = mods.filter(m => m.type === 'oscillator');
const mixer = mods.find(m => m.type === 'mixer');
const out = mods.find(m => m.type === 'output');
if (oscs.length < 2 || !mixer || !out) return false;
const bothSaw = oscs.every(o => o.params.waveform === 'sawtooth');
const mixToOut = conns.some(c => c.from.moduleId === mixer.id && c.to.moduleId === out.id);
return bothSaw && mixToOut;
},
},
{
star: 2,
name: 'Con detune',
desc: 'Al menos un oscilador tiene detune distinto de 0',
test: (mods) => {
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
if (oscs.length < 2) return false;
const detunes = oscs.map(o => o.params.detune ?? 0);
return detunes.some(d => Math.abs(d) > 2);
},
},
{
star: 3,
name: 'Unísono perfecto',
desc: 'Detune entre ±5 y ±15 cents (sutil pero gordo)',
test: (mods) => {
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
if (oscs.length < 2) return false;
const detunes = oscs.map(o => Math.abs(o.params.detune ?? 0)).sort((a, b) => a - b);
// One should be near 0 or opposite, total spread should be 5-30 cents
const spread = Math.abs(detunes[detunes.length - 1] - detunes[0]);
const maxDetune = Math.max(...oscs.map(o => Math.abs(o.params.detune ?? 0)));
return maxDetune >= 5 && maxDetune <= 50;
},
},
],
},
// ─────────────── LEVEL 1.8: BOSS ───────────────
{
id: 'w1-8',
title: 'Acorde Mayor',
subtitle: 'BOSS: Do Mayor',
description: 'Pon todo junto. Un acorde mayor se construye con tres notas: la fundamental, la tercera mayor, y la quinta. Do Mayor = C4 (262 Hz) + E4 (330 Hz) + G4 (392 Hz). Constrúyelo.',
concept: 'C4=262Hz, E4=330Hz, G4=392Hz. Necesitas 3 osciladores, un mixer para combinarlos, y la salida. Las frecuencias no tienen que ser exactas — usa tu oído para comparar con el sonido objetivo.',
availableModules: ['oscillator', 'mixer'],
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 160, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 262 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 330 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 392 } },
],
duration: 3,
},
checks: [
{
star: 1,
name: 'Tres voces',
desc: 'Tres osciladores conectados al mixer y a la salida',
test: (mods, conns) => {
const oscs = mods.filter(m => m.type === 'oscillator');
const mixer = mods.find(m => m.type === 'mixer');
const out = mods.find(m => m.type === 'output');
if (oscs.length < 3 || !mixer || !out) return false;
const mixToOut = conns.some(c => c.from.moduleId === mixer.id && c.to.moduleId === out.id);
const count = oscs.filter(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === mixer.id)).length;
return mixToOut && count >= 3;
},
},
{
star: 2,
name: 'Acorde cercano',
desc: 'Frecuencias cerca de C4, E4, G4 (±30 Hz)',
test: (mods) => {
const oscs = mods.filter(m => m.type === 'oscillator');
if (oscs.length < 3) return false;
const freqs = oscs.map(o => o.params.frequency ?? 440).sort((a, b) => a - b);
const targets = [262, 330, 392];
return targets.every((t, i) => Math.abs(freqs[i] - t) <= 30);
},
},
{
star: 3,
name: 'Do Mayor perfecto',
desc: 'C4=262, E4=330, G4=392 Hz (±10 Hz)',
test: (mods) => {
const oscs = mods.filter(m => m.type === 'oscillator');
if (oscs.length < 3) return false;
const freqs = oscs.map(o => o.params.frequency ?? 440).sort((a, b) => a - b);
const targets = [262, 330, 392];
return targets.every((t, i) => Math.abs(freqs[i] - t) <= 10);
},
},
],
},
],
};

63
src/game/targetAudio.js Normal file
View File

@@ -0,0 +1,63 @@
/**
* targetAudio.js — Plays the "target" sound for a puzzle level
* Builds a temporary Tone.js graph from the level's target config
*/
import * as Tone from 'tone';
let _activeNodes = [];
let _isPlaying = false;
let _stopTimeout = null;
export function isTargetPlaying() {
return _isPlaying;
}
export async function playTarget(target) {
if (_isPlaying) {
stopTarget();
return;
}
await Tone.start();
_isPlaying = true;
const nodes = [];
const output = new Tone.Gain(0.5).toDestination();
nodes.push(output);
// Build oscillators from target.build
for (const spec of target.build) {
if (spec.type === 'oscillator') {
const osc = new Tone.Oscillator({
type: spec.params.waveform || 'sine',
frequency: spec.params.frequency || 440,
detune: spec.params.detune || 0,
});
osc.connect(output);
osc.start();
nodes.push(osc);
}
}
_activeNodes = nodes;
// Auto-stop after duration
const dur = (target.duration || 2) * 1000;
_stopTimeout = setTimeout(() => stopTarget(), dur);
}
export function stopTarget() {
if (_stopTimeout) {
clearTimeout(_stopTimeout);
_stopTimeout = null;
}
for (const node of _activeNodes) {
try {
if (node.stop) node.stop();
if (node.disconnect) node.disconnect();
if (node.dispose) node.dispose();
} catch {}
}
_activeNodes = [];
_isPlaying = false;
}

View File

@@ -246,3 +246,266 @@ html, body, #root {
}
.preset-item:hover { background: var(--surface2); }
.preset-item .preset-date { color: var(--text2); font-size: 10px; }
/* ======================================================
GAME MODE — SynthQuest
====================================================== */
/* ===== World Map ===== */
.gm-worldmap {
height: 100vh; overflow-y: auto;
background: linear-gradient(180deg, #08080f 0%, #0a0a1a 50%, #08080f 100%);
padding: 0 24px 40px;
}
.gm-header {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 0; border-bottom: 1px solid var(--border); margin-bottom: 32px;
}
.gm-logo { display: flex; align-items: center; gap: 12px; }
.gm-logo-icon {
font-size: 36px; color: var(--accent);
width: 56px; height: 56px; display: flex; align-items: center; justify-content: center;
border: 2px solid var(--accent); border-radius: 12px; background: rgba(0,229,255,0.05);
}
.gm-title { font-size: 22px; font-weight: 800; color: var(--text); letter-spacing: 1px; }
.gm-tagline { font-size: 12px; color: var(--text2); margin-top: 2px; }
.gm-header-right { display: flex; align-items: center; gap: 16px; }
.gm-total-stars { font-size: 16px; color: var(--yellow); font-weight: 700; }
.gm-sandbox-btn {
padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--text2); cursor: pointer;
font-size: 12px; font-weight: 600; font-family: inherit; transition: all 0.15s;
}
.gm-sandbox-btn:hover { border-color: var(--accent); color: var(--text); }
/* World sections */
.gm-world-section { margin-bottom: 32px; }
.gm-locked-world { opacity: 0.4; }
.gm-world-header {
display: flex; align-items: center; gap: 12px; margin-bottom: 16px;
}
.gm-world-icon { font-size: 28px; }
.gm-world-name { font-size: 16px; font-weight: 700; color: var(--text); }
.gm-world-sub { font-size: 11px; color: var(--text2); margin-top: 2px; }
/* Level grid */
.gm-level-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.gm-level-card {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; border-radius: 10px; cursor: pointer;
background: var(--surface); border: 1px solid var(--border);
transition: all 0.2s; position: relative; overflow: hidden;
}
.gm-level-card.unlocked:hover {
border-color: var(--accent); transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,229,255,0.1);
}
.gm-level-card.locked { cursor: default; opacity: 0.5; }
.gm-level-card.boss { border-color: var(--yellow); }
.gm-level-card.boss .gm-level-number { background: var(--yellow); color: #000; }
.gm-level-card.perfect { border-color: var(--green); }
.gm-level-number {
width: 36px; height: 36px; border-radius: 50%;
background: var(--surface2); display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 14px; color: var(--accent); flex-shrink: 0;
border: 1px solid var(--border);
}
.gm-level-info { flex: 1; min-width: 0; }
.gm-level-title { font-size: 13px; font-weight: 600; color: var(--text); }
.gm-level-subtitle { font-size: 10px; color: var(--text2); margin-top: 2px; }
.gm-stars { display: flex; gap: 2px; }
.gm-stars .star { font-size: 16px; }
.gm-stars .star.filled { color: var(--yellow); }
.gm-stars .star.empty { color: var(--border); }
.gm-lock { font-size: 18px; }
.gm-lock-overlay {
position: absolute; inset: 0; background: rgba(8,8,15,0.3);
pointer-events: none;
}
/* ===== Puzzle View ===== */
.gm-puzzle { display: flex; flex-direction: column; height: 100vh; }
.gm-puzzle-bar {
height: 48px; background: var(--panel); border-bottom: 1px solid var(--border);
display: flex; align-items: center; padding: 0 16px; gap: 12px;
flex-shrink: 0; z-index: 10;
}
.gm-puzzle-title { display: flex; align-items: center; gap: 8px; }
.gm-puzzle-num {
font-size: 10px; color: var(--text2); background: var(--surface);
padding: 2px 8px; border-radius: 4px; font-weight: 600;
}
.gm-puzzle-name { font-size: 14px; font-weight: 700; color: var(--text); }
.gm-puzzle-actions { margin-left: auto; display: flex; gap: 8px; }
/* Buttons */
.gm-btn {
padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--text); cursor: pointer;
font-size: 12px; font-weight: 600; font-family: inherit; transition: all 0.15s;
white-space: nowrap;
}
.gm-btn:hover { border-color: var(--accent); }
.gm-btn.icon { padding: 6px 10px; }
.gm-btn.primary { background: var(--accent); color: #000; border-color: var(--accent); }
.gm-btn.primary:hover { background: #33ecff; }
.gm-btn.secondary { background: var(--surface2); }
.gm-btn.target { border-color: var(--yellow); color: var(--yellow); }
.gm-btn.target:hover { background: rgba(255,204,0,0.1); }
.gm-btn.check { border-color: var(--green); color: var(--green); }
.gm-btn.check:hover { background: rgba(68,255,136,0.1); }
.gm-btn.active { background: var(--accent); color: #000; border-color: var(--accent); }
.gm-btn.danger { border-color: var(--red); color: var(--red); }
.gm-btn.danger:hover { background: rgba(255,68,102,0.1); }
/* Puzzle layout */
.gm-puzzle-content { flex: 1; display: flex; overflow: hidden; }
.gm-puzzle-sidebar {
width: 280px; flex-shrink: 0; background: var(--panel);
border-right: 1px solid var(--border); overflow-y: auto;
padding: 12px; display: flex; flex-direction: column; gap: 12px;
}
.gm-puzzle-canvas-wrap {
flex: 1; position: relative; overflow: hidden;
}
/* Concept panel */
.gm-concept-panel {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
overflow: hidden;
}
.gm-concept-header {
padding: 10px 12px; cursor: pointer; display: flex; justify-content: space-between;
align-items: center; font-size: 12px; font-weight: 600; color: var(--yellow);
}
.gm-concept-body { padding: 0 12px 12px; }
.gm-concept-desc { font-size: 11px; color: var(--text); line-height: 1.5; margin-bottom: 8px; }
.gm-concept-tip {
font-size: 10px; color: var(--text2); line-height: 1.5;
padding: 8px; background: var(--bg); border-radius: 4px;
border-left: 3px solid var(--accent);
}
/* Objectives */
.gm-objectives {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 10px 12px;
}
.gm-obj-title {
font-size: 10px; font-weight: 700; color: var(--text2);
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;
}
.gm-obj {
display: flex; align-items: center; gap: 8px; padding: 6px 0;
border-bottom: 1px solid var(--border); font-size: 11px;
}
.gm-obj:last-child { border-bottom: none; }
.gm-obj-star { color: var(--yellow); font-size: 12px; flex-shrink: 0; width: 30px; }
.gm-obj-name { flex: 1; color: var(--text2); }
.gm-obj.passed .gm-obj-name { color: var(--green); }
.gm-obj.failed .gm-obj-name { color: var(--text2); }
.gm-obj-check { color: var(--green); font-weight: 700; }
.gm-obj-x { color: var(--red); font-weight: 700; }
/* Module palette (game) */
.gm-module-palette {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 10px 12px;
}
.gm-palette-title {
font-size: 10px; font-weight: 700; color: var(--text2);
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;
}
.gm-palette-item {
display: flex; align-items: center; gap: 8px;
padding: 8px; border-radius: 6px; cursor: pointer;
transition: all 0.15s; font-size: 12px; color: var(--text);
}
.gm-palette-item:hover { background: var(--surface2); }
.gm-palette-icon { font-size: 16px; width: 24px; text-align: center; }
.gm-palette-name { flex: 1; font-weight: 500; }
.gm-palette-add {
width: 22px; height: 22px; border-radius: 50%;
background: var(--surface2); display: flex; align-items: center; justify-content: center;
font-size: 14px; color: var(--accent); font-weight: 700;
}
/* Canvas hint */
.gm-canvas-hint {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
padding: 8px 16px; background: rgba(0,0,0,0.7); border-radius: 8px;
font-size: 11px; color: var(--text2); pointer-events: none;
border: 1px solid var(--border); z-index: 10;
}
/* ===== Level Complete Overlay ===== */
.gm-complete-overlay {
position: fixed; inset: 0; background: rgba(8,8,15,0.85);
display: flex; align-items: center; justify-content: center;
z-index: 200; animation: fadeIn 0.3s;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.gm-complete-card {
background: var(--panel); border: 1px solid var(--border); border-radius: 16px;
padding: 32px 40px; text-align: center; min-width: 400px;
box-shadow: 0 32px 64px rgba(0,0,0,0.5);
animation: slideUp 0.4s ease-out;
}
@keyframes slideUp {
from { transform: translateY(40px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.gm-complete-title { font-size: 20px; font-weight: 800; color: var(--green); margin-bottom: 4px; }
.gm-complete-level { font-size: 13px; color: var(--text2); margin-bottom: 20px; }
.gm-complete-stars { display: flex; justify-content: center; gap: 12px; margin-bottom: 12px; }
.gm-big-star {
font-size: 48px; transition: all 0.4s ease-out;
}
.gm-big-star.empty { color: var(--border); transform: scale(0.8); }
.gm-big-star.earned {
color: var(--yellow); transform: scale(1);
filter: drop-shadow(0 0 12px rgba(255,204,0,0.4));
animation: starPop 0.4s ease-out;
}
@keyframes starPop {
0% { transform: scale(0.3); opacity: 0; }
60% { transform: scale(1.3); }
100% { transform: scale(1); opacity: 1; }
}
.gm-complete-msg { font-size: 13px; color: var(--text2); margin-bottom: 20px; font-style: italic; }
.gm-checks {
margin-bottom: 24px; text-align: left;
background: var(--surface); border-radius: 8px; padding: 12px;
}
.gm-check {
display: flex; align-items: center; gap: 8px; padding: 6px 0;
font-size: 12px; border-bottom: 1px solid var(--border);
}
.gm-check:last-child { border-bottom: none; }
.gm-check-icon { font-size: 14px; width: 20px; text-align: center; }
.gm-check.passed .gm-check-icon { color: var(--green); }
.gm-check.failed .gm-check-icon { color: var(--red); }
.gm-check-name { flex: 1; color: var(--text); }
.gm-check-star { color: var(--yellow); }
.gm-complete-actions { display: flex; gap: 8px; justify-content: center; }

View File

@@ -1,6 +1,17 @@
import React from 'react';
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import GameApp from './game/GameApp.jsx';
import './index.css';
createRoot(document.getElementById('root')).render(<App />);
function Root() {
const [mode, setMode] = useState('game'); // 'game' | 'sandbox'
if (mode === 'sandbox') {
return <App onSwitchToGame={() => setMode('game')} />;
}
return <GameApp onSwitchToSandbox={() => setMode('sandbox')} />;
}
createRoot(document.getElementById('root')).render(<Root />);