diff --git a/src/App.jsx b/src/App.jsx index 13cfa98..94861e0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -59,20 +59,23 @@ export default function App({ onSwitchToGame }) { }, []); // Find port-dot element at pointer position (including nearby) - const findPortAtPoint = (x, y) => { - // First try exact hit - const el = document.elementFromPoint(x, y); - if (el && el.classList.contains('port-dot') && el.dataset.moduleId) { - return el; - } - // Try a small radius around the point - 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; + // Robust port detection — searches all port-dots by bounding rect distance + // instead of elementFromPoint (which gets blocked by SVG wire overlay) + const findPortAtPoint = (clientX, clientY) => { + const portDots = document.querySelectorAll('.port-dot[data-module-id]'); + let closest = null; + let closestDist = 18; + for (const dot of portDots) { + const rect = dot.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const dist = Math.sqrt((clientX - cx) ** 2 + (clientY - cy) ** 2); + if (dist < closestDist) { + closestDist = dist; + closest = dot; } } - return null; + return closest; }; // Canvas pointer events diff --git a/src/game/LevelComplete.jsx b/src/game/LevelComplete.jsx index d2a096c..f30214a 100644 --- a/src/game/LevelComplete.jsx +++ b/src/game/LevelComplete.jsx @@ -1,10 +1,9 @@ import React, { useEffect, useState } from 'react'; -export default function LevelComplete({ stars, checks, levelTitle, onNext, onRetry, onMap, isLastLevel }) { +export default function LevelComplete({ stars, checks, levelTitle, onNext, onRetry, onMap, isLastLevel, hintPenalty }) { 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)); @@ -13,9 +12,9 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet }, [stars]); const messages = [ - '', // 0 stars + '', 'Has dado el primer paso...', - 'Buen trabajo — casi perfecto.', + hintPenalty ? 'Reinicia sin pista para conseguir 3 estrellas.' : 'Buen trabajo — casi perfecto.', 'Ejecucion impecable.', ]; @@ -27,21 +26,25 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet

{levelTitle}

- {/* Stars */}
{[1, 2, 3].map(i => ( - ★ + {i === 3 && hintPenalty ? '🔒' : '★'} ))}

{messages[stars] || ''}

- {/* Checks */} + {hintPenalty && ( +

+ Usaste la pista — tercera estrella bloqueada +

+ )} +
{checks.map((check, i) => (
@@ -52,10 +55,11 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet ))}
- {/* Actions */}
- + {stars >= 1 && !isLastLevel && ( )} diff --git a/src/game/PuzzleView.jsx b/src/game/PuzzleView.jsx index 5f89ef5..590265d 100644 --- a/src/game/PuzzleView.jsx +++ b/src/game/PuzzleView.jsx @@ -14,17 +14,16 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN 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 [hintUsed, setHintUsed] = useState(false); + const [showHint, setShowHint] = useState(false); + const [result, setResult] = useState(null); 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 () => { @@ -34,31 +33,25 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN }, [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 }, + 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); + setHintUsed(false); + setShowHint(false); 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(); @@ -71,14 +64,23 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN }); }, []); - 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; + // Robust port detection — searches all port-dots by bounding rect distance + // instead of elementFromPoint (which gets blocked by SVG wire overlay) + const findPortAtPoint = (clientX, clientY) => { + const portDots = document.querySelectorAll('.port-dot[data-module-id]'); + let closest = null; + let closestDist = 18; + for (const dot of portDots) { + const rect = dot.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const dist = Math.sqrt((clientX - cx) ** 2 + (clientY - cy) ** 2); + if (dist < closestDist) { + closestDist = dist; + closest = dot; } } - return null; + return closest; }; const handlePointerDown = useCallback((e) => { @@ -166,7 +168,6 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN 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; @@ -174,7 +175,6 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN if (state.isRunning) rebuildGraph(); }; - // Toggle player audio const handleToggleAudio = async () => { if (state.isRunning) { stopAudio(); @@ -184,7 +184,6 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN emit(); }; - // Play target sound const handlePlayTarget = async () => { if (isTargetPlaying()) { stopTarget(); @@ -192,12 +191,16 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN } else { setTargetPlaying(true); await playTarget(level.target); - // Auto-update when target stops setTimeout(() => setTargetPlaying(false), (level.target.duration || 2) * 1000 + 100); } }; - // Validate solution + // Reveal hint — permanently caps this attempt at 2 stars + const handleRevealHint = () => { + setHintUsed(true); + setShowHint(true); + }; + const handleCheck = () => { const mods = state.modules; const conns = state.connections; @@ -206,14 +209,16 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN 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 }); + // Cap at 2 stars if hint was used + if (hintUsed && stars > 2) stars = 2; + + setResult({ stars, checks, hintPenalty: hintUsed && stars >= 2 }); if (stars >= 1) { completeLevel(level.id, stars); @@ -251,20 +256,33 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
- {/* Left sidebar: concept + module palette */} + {/* Left sidebar */}
- {/* Concept panel */} + {/* Description — always visible */}
-
setShowConcept(!showConcept)}> - 💡 Concepto - {showConcept ? '▼' : '▶'} +
+ 📖 Mision
- {showConcept && ( -
-

{level.description}

-
- Pista: {level.concept} +
+

{level.description}

+
+
+ + {/* Hint — hidden, reveals with penalty */} +
+ {!showHint ? ( + + ) : ( +
+
+ 💡 Pista + max ★★
+

{level.concept}

)}
@@ -274,18 +292,27 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
Objetivos
{level.checks.map((check, i) => { const passed = result?.checks?.[i]?.passed; + const cappedByStar = hintUsed && check.star === 3; return ( -
+
{'★'.repeat(check.star)} - {check.desc} - {passed === true && } + + {check.desc} + {cappedByStar && 🔒} + + {passed === true && !cappedByStar && } {passed === false && }
); })} + {hintUsed && ( +
+ Pista usada — maximo 2 estrellas. Reinicia para intentar sin pista. +
+ )}
- {/* Module palette for this level */} + {/* Module palette */} {level.availableModules.length > 0 && (
Modulos Disponibles
@@ -293,11 +320,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN const def = getModuleDef(type); if (!def) return null; return ( -
handleAddModule(type)} - > +
handleAddModule(type)}> {def.icon} {def.name} + @@ -307,7 +330,6 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
)} - {/* Reset button */} @@ -324,7 +346,6 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN onWheel={handleWheel} onContextMenu={handleContextMenu} > - {/* Grid */} - {/* Wires */} - {/* Modules */}
{state.modules.map(mod => (
- {/* Canvas hints */} {state.modules.length > 0 && state.connections.length === 0 && (
Arrastra de un puerto (circulo) a otro para conectar modulos @@ -369,6 +387,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN checks={result.checks} levelTitle={level.title} isLastLevel={isLastLevel} + hintPenalty={result.hintPenalty} onRetry={loadLevel} onMap={onBack} onNext={onNextLevel} diff --git a/src/index.css b/src/index.css index 4947795..2dc1449 100644 --- a/src/index.css +++ b/src/index.css @@ -421,6 +421,54 @@ html, body, #root { .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; } +.gm-obj.capped .gm-obj-name { color: var(--text2); text-decoration: line-through; } +.gm-obj-locked { color: var(--text2); font-size: 10px; } + +.gm-hint-warning { + margin-top: 8px; padding: 6px 8px; background: rgba(255,204,0,0.08); + border-radius: 4px; font-size: 10px; color: var(--yellow); line-height: 1.4; +} + +/* Hint panel */ +.gm-hint-panel { } +.gm-hint-btn { + width: 100%; 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; + font-family: inherit; transition: all 0.15s; +} +.gm-hint-btn:hover { background: rgba(255,204,0,0.1); border-style: solid; } +.gm-hint-icon { font-size: 16px; } +.gm-hint-label { font-size: 12px; font-weight: 600; color: var(--yellow); flex: 1; text-align: left; } +.gm-hint-penalty { + font-size: 9px; color: var(--red); background: rgba(255,68,102,0.15); + padding: 2px 6px; border-radius: 3px; font-weight: 700; +} + +.gm-hint-revealed { + background: var(--surface); border: 1px solid var(--yellow); border-radius: 8px; + overflow: hidden; +} +.gm-hint-header { + padding: 8px 12px; display: flex; justify-content: space-between; align-items: center; + font-size: 12px; font-weight: 600; color: var(--yellow); + background: rgba(255,204,0,0.06); +} +.gm-hint-penalty-tag { + font-size: 9px; color: var(--red); background: rgba(255,68,102,0.15); + padding: 2px 6px; border-radius: 3px; font-weight: 700; +} +.gm-hint-text { + padding: 8px 12px 12px; font-size: 11px; color: var(--text); line-height: 1.5; +} + +.gm-hint-penalty-msg { + font-size: 11px; color: var(--yellow); margin-bottom: 16px; +} + +.gm-big-star.locked { + color: var(--border); font-size: 36px; +} /* Module palette (game) */ .gm-module-palette {