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}
+
+
+
+ {/* 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 {