From e077e7f5535f5569a8dc7f122c2a3e399194dcd5 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 02:15:07 +0100 Subject: [PATCH] fix: wire connections + hint system with star penalty - Replace elementFromPoint with bounding rect distance search for port detection. The SVG wire overlay was intercepting pointer events, requiring users to wait for animations before connecting. Now finds the closest port-dot within 18px radius regardless of z-index. - Add hint system: concept text hidden behind "Mostrar Pista" button. Using the hint permanently caps the level at 2 stars max. Reiniciar resets the penalty. Visual feedback in objectives and completion screen. Co-Authored-By: Claude Opus 4.6 --- src/App.jsx | 27 +++++---- src/game/LevelComplete.jsx | 24 ++++---- src/game/PuzzleView.jsx | 119 +++++++++++++++++++++---------------- src/index.css | 48 +++++++++++++++ 4 files changed, 146 insertions(+), 72 deletions(-) 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 {