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 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 02:15:07 +01:00
parent 08206e996e
commit e077e7f553
4 changed files with 146 additions and 72 deletions

View File

@@ -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

View File

@@ -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
</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'}`}
className={`gm-big-star ${i <= showStars ? 'earned' : 'empty'} ${i === 3 && hintPenalty ? 'locked' : ''}`}
>
{i === 3 && hintPenalty ? '🔒' : '★'}
</span>
))}
</div>
<p className="gm-complete-msg">{messages[stars] || ''}</p>
{/* Checks */}
{hintPenalty && (
<p className="gm-hint-penalty-msg">
Usaste la pista tercera estrella bloqueada
</p>
)}
<div className="gm-checks">
{checks.map((check, i) => (
<div key={i} className={`gm-check ${check.passed ? 'passed' : 'failed'}`}>
@@ -52,10 +55,11 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet
))}
</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>
<button className="gm-btn secondary" onClick={onRetry}>
{hintPenalty ? '↺ Sin pista' : 'Reintentar'}
</button>
{stars >= 1 && !isLastLevel && (
<button className="gm-btn primary" onClick={onNext}>Siguiente </button>
)}

View File

@@ -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
</div>
<div className="gm-puzzle-content">
{/* Left sidebar: concept + module palette */}
{/* Left sidebar */}
<div className="gm-puzzle-sidebar">
{/* Concept panel */}
{/* Description — always visible */}
<div className="gm-concept-panel">
<div className="gm-concept-header" onClick={() => setShowConcept(!showConcept)}>
<span>💡 Concepto</span>
<span>{showConcept ? '▼' : '▶'}</span>
<div className="gm-concept-header">
<span>📖 Mision</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 className="gm-concept-body">
<p className="gm-concept-desc">{level.description}</p>
</div>
</div>
{/* Hint — hidden, reveals with penalty */}
<div className="gm-hint-panel">
{!showHint ? (
<button className="gm-hint-btn" onClick={handleRevealHint}>
<span className="gm-hint-icon">💡</span>
<span className="gm-hint-label">Mostrar Pista</span>
<span className="gm-hint-penalty">max </span>
</button>
) : (
<div className="gm-hint-revealed">
<div className="gm-hint-header">
<span>💡 Pista</span>
<span className="gm-hint-penalty-tag">max </span>
</div>
<p className="gm-hint-text">{level.concept}</p>
</div>
)}
</div>
@@ -274,18 +292,27 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
<div className="gm-obj-title">Objetivos</div>
{level.checks.map((check, i) => {
const passed = result?.checks?.[i]?.passed;
const cappedByStar = hintUsed && check.star === 3;
return (
<div key={i} className={`gm-obj ${passed === true ? 'passed' : passed === false ? 'failed' : ''}`}>
<div key={i} className={`gm-obj ${passed === true ? (cappedByStar ? 'capped' : '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>}
<span className="gm-obj-name">
{check.desc}
{cappedByStar && <span className="gm-obj-locked"> 🔒</span>}
</span>
{passed === true && !cappedByStar && <span className="gm-obj-check"></span>}
{passed === false && <span className="gm-obj-x"></span>}
</div>
);
})}
{hintUsed && (
<div className="gm-hint-warning">
Pista usada maximo 2 estrellas. Reinicia para intentar sin pista.
</div>
)}
</div>
{/* Module palette for this level */}
{/* Module palette */}
{level.availableModules.length > 0 && (
<div className="gm-module-palette">
<div className="gm-palette-title">Modulos Disponibles</div>
@@ -293,11 +320,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
const def = getModuleDef(type);
if (!def) return null;
return (
<div
key={type}
className="gm-palette-item"
onClick={() => handleAddModule(type)}
>
<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>
@@ -307,7 +330,6 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
</div>
)}
{/* Reset button */}
<button className="gm-btn danger" onClick={loadLevel} style={{ marginTop: 'auto' }}>
Reiniciar Nivel
</button>
@@ -324,7 +346,6 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
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}
@@ -336,10 +357,8 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
<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
@@ -353,7 +372,6 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
</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
@@ -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}

View File

@@ -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 {