feat: add mobile-responsive UI for all views
- Add useIsMobile hook (768px breakpoint with matchMedia) - Add BottomSheet component (swipe up/down, optional tabs, handle bar) - Add MobileTabBar component (bottom nav with icons + labels) - Sandbox mobile: compact toolbar, hamburger menu, action bar with START button, bottom sheet with module grid tiles - World Map mobile: compact header, single-column level list, bottom tab bar (JUEGO/SANDBOX/CONFIG) - Puzzle View mobile: icon-only top bar buttons, sidebar replaced by bottom sheet with 3 tabs (MISION/OBJETIVOS/MODULOS) - ~200 lines of CSS media queries: touch targets 44px, port dots 18px, zoom controls larger, modals full-width, level complete full-width buttons Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
108
src/App.jsx
108
src/App.jsx
@@ -8,7 +8,10 @@ import ModuleNode from './components/ModuleNode.jsx';
|
|||||||
import WireLayer from './components/WireLayer.jsx';
|
import WireLayer from './components/WireLayer.jsx';
|
||||||
import ModulePalette from './components/ModulePalette.jsx';
|
import ModulePalette from './components/ModulePalette.jsx';
|
||||||
import PresetModal from './components/PresetModal.jsx';
|
import PresetModal from './components/PresetModal.jsx';
|
||||||
|
import BottomSheet from './components/BottomSheet.jsx';
|
||||||
import { CHIPTUNE_PRESET } from './presets/chiptune.js';
|
import { CHIPTUNE_PRESET } from './presets/chiptune.js';
|
||||||
|
import { useIsMobile } from './hooks/useIsMobile.js';
|
||||||
|
import { getModulesByCategory } from './engine/moduleRegistry.js';
|
||||||
|
|
||||||
export default function App({ onSwitchToGame }) {
|
export default function App({ onSwitchToGame }) {
|
||||||
const [, forceUpdate] = useState(0);
|
const [, forceUpdate] = useState(0);
|
||||||
@@ -18,6 +21,8 @@ export default function App({ onSwitchToGame }) {
|
|||||||
const connectingRef = useRef(null);
|
const connectingRef = useRef(null);
|
||||||
const [presetModal, setPresetModal] = useState(null);
|
const [presetModal, setPresetModal] = useState(null);
|
||||||
const importRef = useRef(null);
|
const importRef = useRef(null);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
// Subscribe to state changes
|
// Subscribe to state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -249,44 +254,82 @@ export default function App({ onSwitchToGame }) {
|
|||||||
emit();
|
emit();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Flatten all modules for mobile grid
|
||||||
|
const allModuleDefs = Object.values(getModulesByCategory()).flat();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
{onSwitchToGame && (
|
{onSwitchToGame && !isMobile && (
|
||||||
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
|
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
|
||||||
🎮 Game
|
🎮 Game
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="toolbar-title">Reaktor</span>
|
<span className="toolbar-title">Reaktor</span>
|
||||||
<div className="toolbar-sep" />
|
{!isMobile && <div className="toolbar-sep" />}
|
||||||
<button className={`toolbar-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
|
{!isMobile && (
|
||||||
|
<button className={`toolbar-btn start-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
|
||||||
{state.isRunning ? '⏹ Stop' : '▶ Start'}
|
{state.isRunning ? '⏹ Stop' : '▶ Start'}
|
||||||
</button>
|
</button>
|
||||||
<div className="toolbar-sep" />
|
)}
|
||||||
|
{!isMobile && <div className="toolbar-sep" />}
|
||||||
|
{!isMobile && (
|
||||||
<div className="toolbar-group">
|
<div className="toolbar-group">
|
||||||
<button className="toolbar-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
|
<button className="toolbar-btn save-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
|
||||||
<button className="toolbar-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
|
<button className="toolbar-btn load-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
|
||||||
<button className="toolbar-btn" onClick={exportPatch}>📤 Export</button>
|
<button className="toolbar-btn export-btn" onClick={exportPatch}>📤 Export</button>
|
||||||
<button className="toolbar-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
|
<button className="toolbar-btn import-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
|
||||||
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar-sep" />
|
)}
|
||||||
<button className="toolbar-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
|
{!isMobile && <div className="toolbar-sep" />}
|
||||||
|
{!isMobile && (
|
||||||
|
<>
|
||||||
|
<button className="toolbar-btn demo-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
|
||||||
🎮 Chiptune Demo
|
🎮 Chiptune Demo
|
||||||
</button>
|
</button>
|
||||||
<button className="toolbar-btn" onClick={handleClearCanvas}>
|
<button className="toolbar-btn clear-btn" onClick={handleClearCanvas}>
|
||||||
🗑 Limpiar
|
🗑 Limpiar
|
||||||
</button>
|
</button>
|
||||||
<div className="toolbar-sep" />
|
<div className="toolbar-sep" />
|
||||||
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)' }}>
|
</>
|
||||||
|
)}
|
||||||
|
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)', marginLeft: isMobile ? 'auto' : undefined }}>
|
||||||
{state.isRunning ? '● LIVE' : '○ OFF'}
|
{state.isRunning ? '● LIVE' : '○ OFF'}
|
||||||
</span>
|
</span>
|
||||||
|
{!isMobile && (
|
||||||
<span className="toolbar-label" style={{ marginLeft: 'auto' }}>
|
<span className="toolbar-label" style={{ marginLeft: 'auto' }}>
|
||||||
{state.modules.length} modules · {state.connections.length} wires
|
{state.modules.length} modules · {state.connections.length} wires
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
{isMobile && (
|
||||||
|
<button className="mobile-menu-btn" onClick={() => setMenuOpen(true)}>≡</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile menu overlay */}
|
||||||
|
{isMobile && menuOpen && (
|
||||||
|
<div className="mobile-menu-overlay" onClick={() => setMenuOpen(false)}>
|
||||||
|
<div className="mobile-menu-panel" onClick={e => e.stopPropagation()}>
|
||||||
|
{onSwitchToGame && (
|
||||||
|
<button className="toolbar-btn" onClick={() => { setMenuOpen(false); onSwitchToGame(); }} style={{ color: 'var(--yellow)' }}>
|
||||||
|
🎮 Game
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="toolbar-btn" onClick={() => { setPresetModal('save'); setMenuOpen(false); }}>💾 Save</button>
|
||||||
|
<button className="toolbar-btn" onClick={() => { setPresetModal('load'); setMenuOpen(false); }}>📂 Load</button>
|
||||||
|
<button className="toolbar-btn" onClick={() => { exportPatch(); setMenuOpen(false); }}>📤 Export</button>
|
||||||
|
<button className="toolbar-btn" onClick={() => { importRef.current?.click(); setMenuOpen(false); }}>📥 Import</button>
|
||||||
|
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||||||
|
<button className="toolbar-btn" onClick={() => { handleLoadDemo(); setMenuOpen(false); }} style={{ color: 'var(--yellow)' }}>
|
||||||
|
🎮 Chiptune Demo
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn" onClick={() => { handleClearCanvas(); setMenuOpen(false); }}>🗑 Limpiar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main canvas area */}
|
{/* Main canvas area */}
|
||||||
<div className="main-area">
|
<div className="main-area">
|
||||||
<div
|
<div
|
||||||
@@ -310,10 +353,10 @@ export default function App({ onSwitchToGame }) {
|
|||||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Wire layer (behind modules, uses getBoundingClientRect) */}
|
{/* Wire layer */}
|
||||||
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
|
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
|
||||||
|
|
||||||
{/* Modules container (offset by camera) */}
|
{/* Modules container */}
|
||||||
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
||||||
{state.modules.map(mod => (
|
{state.modules.map(mod => (
|
||||||
<ModuleNode
|
<ModuleNode
|
||||||
@@ -327,7 +370,7 @@ export default function App({ onSwitchToGame }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zoom controls — top right of canvas */}
|
{/* Zoom controls */}
|
||||||
<div className="zoom-controls">
|
<div className="zoom-controls">
|
||||||
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom in">+</button>
|
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom in">+</button>
|
||||||
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Reset zoom">
|
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Reset zoom">
|
||||||
@@ -337,11 +380,40 @@ export default function App({ onSwitchToGame }) {
|
|||||||
<button className="zoom-btn" onClick={handleCenterView} title="Centrar vista">⌂</button>
|
<button className="zoom-btn" onClick={handleCenterView} title="Centrar vista">⌂</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Module palette */}
|
{/* Desktop palette */}
|
||||||
<ModulePalette onAddModule={handleAddModule} />
|
{!isMobile && <ModulePalette onAddModule={handleAddModule} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status bar */}
|
{/* Mobile action bar */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="mobile-action-bar">
|
||||||
|
<button
|
||||||
|
className={`start-btn-mobile ${state.isRunning ? 'active' : ''}`}
|
||||||
|
onClick={handleToggleAudio}
|
||||||
|
>
|
||||||
|
{state.isRunning ? '⏹ STOP' : '▶ START'}
|
||||||
|
</button>
|
||||||
|
<button className="action-icon-btn" onClick={() => setPresetModal('save')}>💾</button>
|
||||||
|
<button className="action-icon-btn" onClick={exportPatch}>📤</button>
|
||||||
|
<button className="action-icon-btn" onClick={handleClearCanvas}>🗑</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile bottom sheet with modules */}
|
||||||
|
{isMobile && (
|
||||||
|
<BottomSheet>
|
||||||
|
<div className="mobile-module-grid">
|
||||||
|
{allModuleDefs.map(def => (
|
||||||
|
<div key={def.type} className="mobile-module-tile" onClick={() => handleAddModule(def.type)}>
|
||||||
|
<span className="tile-icon">{def.icon}</span>
|
||||||
|
<span className="tile-name">{def.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status bar (hidden on mobile via CSS) */}
|
||||||
<div className="status-bar">
|
<div className="status-bar">
|
||||||
<span className="status-accent">Reaktor — MontLab Modular Synth</span>
|
<span className="status-accent">Reaktor — MontLab Modular Synth</span>
|
||||||
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>
|
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>
|
||||||
|
|||||||
49
src/components/BottomSheet.jsx
Normal file
49
src/components/BottomSheet.jsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
export default function BottomSheet({ tabs, activeTab, onTabChange, children, className = '' }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const startY = useRef(0);
|
||||||
|
const sheetRef = useRef(null);
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback((e) => {
|
||||||
|
startY.current = e.touches[0].clientY;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback((e) => {
|
||||||
|
const deltaY = e.changedTouches[0].clientY - startY.current;
|
||||||
|
if (deltaY < -40) setExpanded(true);
|
||||||
|
if (deltaY > 40) setExpanded(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sheetRef}
|
||||||
|
className={`bottom-sheet ${expanded ? 'expanded' : ''} ${className}`}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
<div className="bottom-sheet-handle" onClick={() => setExpanded(e => !e)}>
|
||||||
|
<div className="bottom-sheet-handle-bar" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tabs && tabs.length > 0 && (
|
||||||
|
<div className="bottom-sheet-tabs">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
className={`bottom-sheet-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||||
|
onClick={() => onTabChange?.(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{activeTab === tab.id && <div className="bottom-sheet-tab-line" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bottom-sheet-content">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/components/MobileTabBar.jsx
Normal file
16
src/components/MobileTabBar.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export default function MobileTabBar({ tabs, activeTab, onTabChange }) {
|
||||||
|
return (
|
||||||
|
<nav className="mobile-tab-bar">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
className={`mobile-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||||
|
onClick={() => onTabChange(tab.id)}
|
||||||
|
>
|
||||||
|
<span className="mobile-tab-icon">{tab.icon}</span>
|
||||||
|
<span className="mobile-tab-label">{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { startAudio, stopAudio, connectWire, rebuildGraph } from '../engine/audi
|
|||||||
import { getModuleDef } from '../engine/moduleRegistry.js';
|
import { getModuleDef } from '../engine/moduleRegistry.js';
|
||||||
import ModuleNode from '../components/ModuleNode.jsx';
|
import ModuleNode from '../components/ModuleNode.jsx';
|
||||||
import WireLayer from '../components/WireLayer.jsx';
|
import WireLayer from '../components/WireLayer.jsx';
|
||||||
|
import BottomSheet from '../components/BottomSheet.jsx';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile.js';
|
||||||
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
|
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
|
||||||
import LevelComplete from './LevelComplete.jsx';
|
import LevelComplete from './LevelComplete.jsx';
|
||||||
import { completeLevel, saveLevelPatch, getLevelPatch, clearLevelPatch, markHintUsed, wasHintUsed } from './gameState.js';
|
import { completeLevel, saveLevelPatch, getLevelPatch, clearLevelPatch, markHintUsed, wasHintUsed } from './gameState.js';
|
||||||
@@ -20,6 +22,8 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
const [showHint, setShowHint] = useState(false);
|
const [showHint, setShowHint] = useState(false);
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [targetPlaying, setTargetPlaying] = useState(false);
|
const [targetPlaying, setTargetPlaying] = useState(false);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [mobileTab, setMobileTab] = useState('mission');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = subscribe(() => {
|
const unsub = subscribe(() => {
|
||||||
@@ -335,7 +339,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
<div className="gm-puzzle">
|
<div className="gm-puzzle">
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<div className="gm-puzzle-bar">
|
<div className="gm-puzzle-bar">
|
||||||
<button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}>← Mapa</button>
|
<button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}>{isMobile ? '←' : '← Mapa'}</button>
|
||||||
<div className="gm-puzzle-title">
|
<div className="gm-puzzle-title">
|
||||||
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
|
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
|
||||||
<span className="gm-puzzle-name">{level.title}</span>
|
<span className="gm-puzzle-name">{level.title}</span>
|
||||||
@@ -345,19 +349,21 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
className={`gm-btn ${targetPlaying ? 'active' : 'target'}`}
|
className={`gm-btn ${targetPlaying ? 'active' : 'target'}`}
|
||||||
onClick={handlePlayTarget}
|
onClick={handlePlayTarget}
|
||||||
>
|
>
|
||||||
{targetPlaying ? '⏹ Parar' : '🎯 Objetivo'}
|
{targetPlaying ? '⏹' : '🎯'}{!isMobile && <span className="btn-label">{targetPlaying ? ' Parar' : ' Objetivo'}</span>}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`gm-btn ${state.isRunning ? 'active' : ''}`}
|
className={`gm-btn ${state.isRunning ? 'active' : ''}`}
|
||||||
onClick={handleToggleAudio}
|
onClick={handleToggleAudio}
|
||||||
>
|
>
|
||||||
{state.isRunning ? '⏹ Parar' : '▶ Mi Sonido'}
|
{state.isRunning ? '⏹' : '▶'}{!isMobile && <span className="btn-label">{state.isRunning ? ' Parar' : ' Mi Sonido'}</span>}
|
||||||
</button>
|
</button>
|
||||||
|
{!isMobile && (
|
||||||
<button className="gm-btn clear" onClick={handleClearCanvas} title="Limpiar canvas">
|
<button className="gm-btn clear" onClick={handleClearCanvas} title="Limpiar canvas">
|
||||||
🗑 Limpiar
|
🗑 Limpiar
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button className="gm-btn check" onClick={handleCheck}>
|
<button className="gm-btn check" onClick={handleCheck}>
|
||||||
✓ Comprobar
|
✓{!isMobile && <span className="btn-label"> Comprobar</span>}
|
||||||
</button>
|
</button>
|
||||||
{adminMode && (
|
{adminMode && (
|
||||||
<button className="gm-btn admin-solve" onClick={handleAutoSolve} title="Admin: resolver nivel">
|
<button className="gm-btn admin-solve" onClick={handleAutoSolve} title="Admin: resolver nivel">
|
||||||
@@ -368,7 +374,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="gm-puzzle-content">
|
<div className="gm-puzzle-content">
|
||||||
{/* Left sidebar */}
|
{/* Left sidebar (desktop only — hidden on mobile via CSS) */}
|
||||||
<div className="gm-puzzle-sidebar">
|
<div className="gm-puzzle-sidebar">
|
||||||
{/* Description — always visible */}
|
{/* Description — always visible */}
|
||||||
<div className="gm-concept-panel">
|
<div className="gm-concept-panel">
|
||||||
@@ -502,6 +508,89 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile bottom sheet with tabs (replaces sidebar) */}
|
||||||
|
{isMobile && (
|
||||||
|
<BottomSheet
|
||||||
|
tabs={[
|
||||||
|
{ id: 'mission', label: 'MISION' },
|
||||||
|
{ id: 'objectives', label: 'OBJETIVOS' },
|
||||||
|
{ id: 'modules', label: 'MODULOS' },
|
||||||
|
]}
|
||||||
|
activeTab={mobileTab}
|
||||||
|
onTabChange={setMobileTab}
|
||||||
|
>
|
||||||
|
{mobileTab === 'mission' && (
|
||||||
|
<div>
|
||||||
|
<p className="puzzle-mission-text">{level.description}</p>
|
||||||
|
{!showHint ? (
|
||||||
|
<button className="puzzle-hint-btn" onClick={handleRevealHint}>
|
||||||
|
<span className="puzzle-hint-icon">💡</span>
|
||||||
|
<span className="puzzle-hint-label">Mostrar Pista</span>
|
||||||
|
<span className="puzzle-hint-penalty">max ★★</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginTop: 8, padding: '10px 12px', background: 'var(--surface)', borderRadius: 8, border: '1px solid var(--yellow)' }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--yellow)', marginBottom: 6 }}>💡 Pista <span className="puzzle-hint-penalty">max ★★</span></div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.5 }}>{level.concept}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mobileTab === 'objectives' && (
|
||||||
|
<div>
|
||||||
|
{level.checks.map((check, i) => {
|
||||||
|
const passed = result?.checks?.[i]?.passed;
|
||||||
|
const cappedByStar = hintUsed && check.star === 3;
|
||||||
|
return (
|
||||||
|
<div key={i} className="puzzle-obj-item">
|
||||||
|
<span className="puzzle-obj-star">{'★'.repeat(check.star)}</span>
|
||||||
|
<span className="puzzle-obj-desc" style={passed === true ? { color: 'var(--green)' } : passed === false ? { color: 'var(--red)' } : {}}>
|
||||||
|
{check.desc}
|
||||||
|
{cappedByStar && ' 🔒'}
|
||||||
|
</span>
|
||||||
|
{passed === true && !cappedByStar && <span style={{ color: 'var(--green)', fontWeight: 700 }}>✓</span>}
|
||||||
|
{passed === false && <span style={{ color: 'var(--red)', fontWeight: 700 }}>✗</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{hintUsed && (
|
||||||
|
<div style={{ marginTop: 8, padding: '6px 8px', background: 'rgba(255,204,0,0.08)', borderRadius: 4, fontSize: 10, color: 'var(--yellow)' }}>
|
||||||
|
Pista usada — maximo 2 estrellas (permanente).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mobileTab === 'modules' && (
|
||||||
|
<div>
|
||||||
|
{level.availableModules.length > 0 ? (
|
||||||
|
<div className="mobile-module-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||||||
|
{level.availableModules.map(type => {
|
||||||
|
const def = getModuleDef(type);
|
||||||
|
if (!def) return null;
|
||||||
|
return (
|
||||||
|
<div key={type} className="mobile-module-tile" onClick={() => handleAddModule(type)}>
|
||||||
|
<span className="tile-icon">{def.icon}</span>
|
||||||
|
<span className="tile-name">{def.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)' }}>No hay modulos extra disponibles para este nivel.</p>
|
||||||
|
)}
|
||||||
|
<button className="gm-btn danger" onClick={() => loadLevel(true)} style={{ width: '100%', marginTop: 12, justifyContent: 'center' }}>
|
||||||
|
↺ Reiniciar Nivel
|
||||||
|
</button>
|
||||||
|
<button className="gm-btn clear" onClick={handleClearCanvas} style={{ width: '100%', marginTop: 6, justifyContent: 'center' }}>
|
||||||
|
🗑 Limpiar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</BottomSheet>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Level complete overlay */}
|
{/* Level complete overlay */}
|
||||||
{result && result.stars >= 1 && (
|
{result && result.stars >= 1 && (
|
||||||
<LevelComplete
|
<LevelComplete
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
|
import MobileTabBar from '../components/MobileTabBar.jsx';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile.js';
|
||||||
import { WORLD_1 } from './levels/world1.js';
|
import { WORLD_1 } from './levels/world1.js';
|
||||||
import { WORLD_2 } from './levels/world2.js';
|
import { WORLD_2 } from './levels/world2.js';
|
||||||
import { WORLD_3 } from './levels/world3.js';
|
import { WORLD_3 } from './levels/world3.js';
|
||||||
@@ -39,11 +41,18 @@ function isWorldUnlocked(world) {
|
|||||||
return getTotalStars() >= world.unlockStars;
|
return getTotalStars() >= world.unlockStars;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MOBILE_TABS = [
|
||||||
|
{ id: 'game', label: 'JUEGO', icon: '🎮' },
|
||||||
|
{ id: 'sandbox', label: 'SANDBOX', icon: '🎛' },
|
||||||
|
{ id: 'config', label: 'CONFIG', icon: '⚙' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
|
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
|
||||||
const totalStars = getTotalStars();
|
const totalStars = getTotalStars();
|
||||||
const maxStars = getMaxStars();
|
const maxStars = getMaxStars();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const searchRef = useRef(null);
|
const searchRef = useRef(null);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const query = search.trim().toLowerCase();
|
const query = search.trim().toLowerCase();
|
||||||
|
|
||||||
@@ -209,6 +218,18 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mobile tab bar */}
|
||||||
|
{isMobile && (
|
||||||
|
<MobileTabBar
|
||||||
|
tabs={MOBILE_TABS}
|
||||||
|
activeTab="game"
|
||||||
|
onTabChange={(id) => {
|
||||||
|
if (id === 'sandbox') onSandbox?.();
|
||||||
|
if (id === 'config') onAdmin?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/hooks/useIsMobile.js
Normal file
14
src/hooks/useIsMobile.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useIsMobile(breakpoint = 768) {
|
||||||
|
const [isMobile, setIsMobile] = useState(() => window.innerWidth <= breakpoint);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${breakpoint}px)`);
|
||||||
|
const handler = (e) => setIsMobile(e.matches);
|
||||||
|
mql.addEventListener('change', handler);
|
||||||
|
return () => mql.removeEventListener('change', handler);
|
||||||
|
}, [breakpoint]);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
233
src/index.css
233
src/index.css
@@ -785,3 +785,236 @@ html, body, #root {
|
|||||||
.admin-star-btn.active { background: var(--yellow); color: var(--bg); border-color: var(--yellow); }
|
.admin-star-btn.active { background: var(--yellow); color: var(--bg); border-color: var(--yellow); }
|
||||||
.admin-star-btn.zero { color: var(--red); }
|
.admin-star-btn.zero { color: var(--red); }
|
||||||
.admin-star-btn.zero:hover { border-color: var(--red); color: var(--red); }
|
.admin-star-btn.zero:hover { border-color: var(--red); color: var(--red); }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MOBILE RESPONSIVE — max-width: 768px
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* --- Bottom Sheet --- */
|
||||||
|
.bottom-sheet {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile Tab Bar --- */
|
||||||
|
.mobile-tab-bar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
|
/* --- Sandbox Toolbar --- */
|
||||||
|
.toolbar { height: 44px; padding: 0 12px; gap: 6px; }
|
||||||
|
.toolbar-title { font-size: 13px; letter-spacing: 0.8px; }
|
||||||
|
.toolbar-sep, .toolbar .status-text,
|
||||||
|
.toolbar-btn.save-btn, .toolbar-btn.load-btn,
|
||||||
|
.toolbar-btn.export-btn, .toolbar-btn.import-btn,
|
||||||
|
.toolbar-btn.demo-btn, .toolbar-btn.clear-btn { display: none; }
|
||||||
|
.toolbar-btn.start-btn { padding: 4px 10px; font-size: 11px; }
|
||||||
|
|
||||||
|
/* Hamburger menu button (added via JS) */
|
||||||
|
.mobile-menu-btn {
|
||||||
|
padding: 6px 10px; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; color: var(--text2); cursor: pointer; font-size: 18px;
|
||||||
|
font-weight: 600; line-height: 1;
|
||||||
|
}
|
||||||
|
.mobile-menu-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||||
|
|
||||||
|
.mobile-menu-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||||
|
z-index: 200; display: flex; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.mobile-menu-panel {
|
||||||
|
width: 260px; background: var(--panel); border-left: 1px solid var(--border);
|
||||||
|
padding: 16px; display: flex; flex-direction: column; gap: 6px;
|
||||||
|
animation: slideInRight 0.2s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
.mobile-menu-panel .toolbar-btn {
|
||||||
|
display: flex; width: 100%; padding: 10px 14px;
|
||||||
|
font-size: 13px; text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile Action Bar (Sandbox) --- */
|
||||||
|
.mobile-action-bar {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 0 12px; height: 48px; background: var(--panel);
|
||||||
|
border-top: 1px solid var(--border); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mobile-action-bar .start-btn-mobile {
|
||||||
|
flex: 1; padding: 8px 14px; background: var(--accent); color: #000;
|
||||||
|
border: none; border-radius: 6px; font-size: 12px; font-weight: 700;
|
||||||
|
font-family: 'JetBrains Mono', monospace; letter-spacing: 1px; cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.mobile-action-bar .start-btn-mobile.active {
|
||||||
|
background: var(--red);
|
||||||
|
}
|
||||||
|
.mobile-action-bar .action-icon-btn {
|
||||||
|
padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; color: var(--text2); cursor: pointer; font-size: 14px;
|
||||||
|
}
|
||||||
|
.mobile-action-bar .action-icon-btn:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* --- Status Bar --- */
|
||||||
|
.status-bar { display: none; }
|
||||||
|
|
||||||
|
/* --- Module Palette --- */
|
||||||
|
.palette { display: none; }
|
||||||
|
|
||||||
|
/* --- Bottom Sheet (visible on mobile) --- */
|
||||||
|
.bottom-sheet {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
background: var(--panel); border-top: 1px solid var(--border);
|
||||||
|
border-radius: 16px 16px 0 0; flex-shrink: 0;
|
||||||
|
max-height: 40vh; transition: max-height 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bottom-sheet.expanded { max-height: 60vh; }
|
||||||
|
|
||||||
|
.bottom-sheet-handle {
|
||||||
|
display: flex; justify-content: center; padding: 10px 0 6px;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.bottom-sheet-handle-bar {
|
||||||
|
width: 40px; height: 4px; background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet-tabs {
|
||||||
|
display: flex; padding: 0 16px; gap: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.bottom-sheet-tab {
|
||||||
|
flex: 1; padding: 8px 0; background: none; border: none;
|
||||||
|
color: var(--text2); font-size: 10px; font-weight: 700;
|
||||||
|
font-family: 'JetBrains Mono', monospace; letter-spacing: 1px;
|
||||||
|
cursor: pointer; text-align: center; position: relative;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.bottom-sheet-tab.active { color: var(--accent); }
|
||||||
|
.bottom-sheet-tab-line {
|
||||||
|
position: absolute; bottom: 0; left: 50%; transform: translateX(-50%);
|
||||||
|
width: 100%; height: 2px; background: var(--accent); border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet-content {
|
||||||
|
padding: 12px 16px; overflow-y: auto; flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Module grid tiles (mobile palette) */
|
||||||
|
.mobile-module-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.mobile-module-tile {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||||
|
padding: 10px 4px; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; cursor: pointer; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.mobile-module-tile:hover, .mobile-module-tile:active {
|
||||||
|
border-color: var(--accent); background: var(--surface2);
|
||||||
|
}
|
||||||
|
.mobile-module-tile .tile-icon { font-size: 20px; }
|
||||||
|
.mobile-module-tile .tile-name {
|
||||||
|
font-size: 9px; font-weight: 600; color: var(--text2);
|
||||||
|
font-family: 'JetBrains Mono', monospace; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Canvas adjustments --- */
|
||||||
|
.node-canvas { cursor: default; }
|
||||||
|
.zoom-controls { right: 8px; top: 8px; }
|
||||||
|
.zoom-btn { width: 40px; height: 36px; min-height: 44px; }
|
||||||
|
.port-dot { width: 18px; height: 18px; }
|
||||||
|
|
||||||
|
/* --- Mobile Tab Bar (visible on mobile) --- */
|
||||||
|
.mobile-tab-bar {
|
||||||
|
display: flex; align-items: center; height: 56px;
|
||||||
|
background: var(--panel); border-top: 1px solid var(--border);
|
||||||
|
flex-shrink: 0; z-index: 10;
|
||||||
|
}
|
||||||
|
.mobile-tab {
|
||||||
|
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 3px; padding: 6px 0; background: none; border: none;
|
||||||
|
cursor: pointer; color: var(--text2); transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.mobile-tab.active { color: var(--accent); }
|
||||||
|
.mobile-tab-icon { font-size: 18px; }
|
||||||
|
.mobile-tab-label {
|
||||||
|
font-size: 9px; font-weight: 600; letter-spacing: 1px;
|
||||||
|
font-family: 'JetBrains Mono', monospace; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- World Map Mobile --- */
|
||||||
|
.gm-worldmap { padding: 0 12px 80px; }
|
||||||
|
.gm-header { padding: 12px 0; gap: 6px; }
|
||||||
|
.gm-header-top { gap: 8px; }
|
||||||
|
.gm-logo-icon { width: 32px; height: 32px; font-size: 16px; }
|
||||||
|
.gm-title { font-size: 18px; }
|
||||||
|
.gm-tagline { display: none; }
|
||||||
|
.gm-header-actions .gm-btn { display: none; }
|
||||||
|
.gm-search-bar { margin: 0 0 12px; }
|
||||||
|
.gm-level-grid { grid-template-columns: 1fr; }
|
||||||
|
.gm-level-card { padding: 10px 12px; }
|
||||||
|
.gm-world-section { margin-bottom: 16px; }
|
||||||
|
|
||||||
|
/* --- Puzzle View Mobile --- */
|
||||||
|
.gm-puzzle-bar { height: 44px; padding: 0 10px; gap: 6px; }
|
||||||
|
.gm-puzzle-bar .gm-btn { padding: 6px 10px; }
|
||||||
|
.gm-puzzle-bar .gm-btn .btn-label { display: none; }
|
||||||
|
.gm-puzzle-name { font-size: 13px; }
|
||||||
|
.gm-puzzle-num { font-size: 9px; padding: 2px 6px; }
|
||||||
|
|
||||||
|
.gm-puzzle-sidebar { display: none; }
|
||||||
|
.gm-puzzle-canvas-wrap { width: 100%; }
|
||||||
|
|
||||||
|
/* Puzzle bottom sheet specific */
|
||||||
|
.puzzle-mission-text {
|
||||||
|
font-size: 12px; color: var(--text); line-height: 1.5;
|
||||||
|
}
|
||||||
|
.puzzle-hint-btn {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 12px; border: 1px dashed var(--yellow);
|
||||||
|
border-radius: 8px; background: rgba(255,204,0,0.04);
|
||||||
|
cursor: pointer; width: 100%; margin-top: 8px;
|
||||||
|
}
|
||||||
|
.puzzle-hint-btn:hover { background: rgba(255,204,0,0.1); border-style: solid; }
|
||||||
|
.puzzle-hint-icon { font-size: 16px; }
|
||||||
|
.puzzle-hint-label { font-size: 12px; font-weight: 600; color: var(--yellow); flex: 1; }
|
||||||
|
.puzzle-hint-penalty {
|
||||||
|
font-size: 9px; font-weight: 700; color: var(--red);
|
||||||
|
background: rgba(255,68,102,0.15); padding: 2px 6px; border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-obj-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 0; border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.puzzle-obj-item:last-child { border-bottom: none; }
|
||||||
|
.puzzle-obj-star { color: var(--yellow); width: 30px; flex-shrink: 0; }
|
||||||
|
.puzzle-obj-desc { color: var(--text2); flex: 1; }
|
||||||
|
|
||||||
|
/* --- Level Complete Modal Mobile --- */
|
||||||
|
.gm-complete-overlay { padding: 0 16px; }
|
||||||
|
.gm-complete-card {
|
||||||
|
min-width: unset; max-width: unset; width: 100%;
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
.gm-complete-actions { flex-direction: column; width: 100%; }
|
||||||
|
.gm-complete-actions .gm-btn { width: 100%; justify-content: center; }
|
||||||
|
.gm-complete-actions .gm-btn.primary {
|
||||||
|
order: -1; padding: 14px;
|
||||||
|
font-size: 13px; font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Preset Modal Mobile --- */
|
||||||
|
.modal { min-width: unset; max-width: unset; width: calc(100% - 32px); }
|
||||||
|
|
||||||
|
/* --- General touch targets --- */
|
||||||
|
.gm-btn { min-height: 44px; display: flex; align-items: center; }
|
||||||
|
.gm-palette-item { padding: 12px 10px; }
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user