Compare commits
2 Commits
08206e996e
...
00c4ec8e00
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00c4ec8e00 | ||
|
|
e077e7f553 |
27
src/App.jsx
27
src/App.jsx
@@ -59,20 +59,23 @@ export default function App({ onSwitchToGame }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Find port-dot element at pointer position (including nearby)
|
// Find port-dot element at pointer position (including nearby)
|
||||||
const findPortAtPoint = (x, y) => {
|
// Robust port detection — searches all port-dots by bounding rect distance
|
||||||
// First try exact hit
|
// instead of elementFromPoint (which gets blocked by SVG wire overlay)
|
||||||
const el = document.elementFromPoint(x, y);
|
const findPortAtPoint = (clientX, clientY) => {
|
||||||
if (el && el.classList.contains('port-dot') && el.dataset.moduleId) {
|
const portDots = document.querySelectorAll('.port-dot[data-module-id]');
|
||||||
return el;
|
let closest = null;
|
||||||
}
|
let closestDist = 18;
|
||||||
// Try a small radius around the point
|
for (const dot of portDots) {
|
||||||
for (const [dx, dy] of [[0,0],[-8,0],[8,0],[0,-8],[0,8],[-6,-6],[6,-6],[-6,6],[6,6]]) {
|
const rect = dot.getBoundingClientRect();
|
||||||
const hit = document.elementFromPoint(x + dx, y + dy);
|
const cx = rect.left + rect.width / 2;
|
||||||
if (hit && hit.classList.contains('port-dot') && hit.dataset.moduleId) {
|
const cy = rect.top + rect.height / 2;
|
||||||
return hit;
|
const dist = Math.sqrt((clientX - cx) ** 2 + (clientY - cy) ** 2);
|
||||||
|
if (dist < closestDist) {
|
||||||
|
closestDist = dist;
|
||||||
|
closest = dot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return closest;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Canvas pointer events
|
// Canvas pointer events
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef, useCallback } from 'react';
|
import React, { useRef, useCallback, useState } from 'react';
|
||||||
|
|
||||||
const SIZE = 32;
|
const SIZE = 32;
|
||||||
const RADIUS = 12;
|
const RADIUS = 12;
|
||||||
@@ -22,6 +22,9 @@ function describeArc(cx, cy, r, startDeg, endDeg) {
|
|||||||
export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue }) {
|
export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue }) {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const dragRef = useRef(null);
|
const dragRef = useRef(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editText, setEditText] = useState('');
|
||||||
|
|
||||||
const norm = Math.max(0, Math.min(1, (value - min) / (max - min)));
|
const norm = Math.max(0, Math.min(1, (value - min) / (max - min)));
|
||||||
const angleDeg = START_ANGLE - norm * RANGE;
|
const angleDeg = START_ANGLE - norm * RANGE;
|
||||||
@@ -40,6 +43,7 @@ export default function Knob({ value, min, max, onChange, color = 'var(--accent)
|
|||||||
value.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
value.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
||||||
|
|
||||||
const handlePointerDown = useCallback((e) => {
|
const handlePointerDown = useCallback((e) => {
|
||||||
|
if (editing) return;
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
dragRef.current = { startY: e.clientY, startValue: value };
|
dragRef.current = { startY: e.clientY, startValue: value };
|
||||||
const handleMove = (me) => {
|
const handleMove = (me) => {
|
||||||
@@ -47,7 +51,6 @@ export default function Knob({ value, min, max, onChange, color = 'var(--accent)
|
|||||||
const sensitivity = (max - min) / 200;
|
const sensitivity = (max - min) / 200;
|
||||||
let newVal = dragRef.current.startValue + dy * sensitivity;
|
let newVal = dragRef.current.startValue + dy * sensitivity;
|
||||||
newVal = Math.max(min, Math.min(max, newVal));
|
newVal = Math.max(min, Math.min(max, newVal));
|
||||||
// Snap to nice values for integer ranges
|
|
||||||
if (max - min > 10 && Number.isInteger(min) && Number.isInteger(max)) {
|
if (max - min > 10 && Number.isInteger(min) && Number.isInteger(max)) {
|
||||||
newVal = Math.round(newVal);
|
newVal = Math.round(newVal);
|
||||||
}
|
}
|
||||||
@@ -60,17 +63,66 @@ export default function Knob({ value, min, max, onChange, color = 'var(--accent)
|
|||||||
};
|
};
|
||||||
window.addEventListener('pointermove', handleMove);
|
window.addEventListener('pointermove', handleMove);
|
||||||
window.addEventListener('pointerup', handleUp);
|
window.addEventListener('pointerup', handleUp);
|
||||||
}, [value, min, max, onChange]);
|
}, [value, min, max, onChange, editing]);
|
||||||
|
|
||||||
const handleWheel = useCallback((e) => {
|
const handleWheel = useCallback((e) => {
|
||||||
|
if (editing) return;
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
const step = (max - min) / 100;
|
const step = (max - min) / 100;
|
||||||
const newVal = Math.max(min, Math.min(max, value - Math.sign(e.deltaY) * step));
|
const newVal = Math.max(min, Math.min(max, value - Math.sign(e.deltaY) * step));
|
||||||
onChange(newVal);
|
onChange(newVal);
|
||||||
}, [value, min, max, onChange]);
|
}, [value, min, max, onChange, editing]);
|
||||||
|
|
||||||
|
// Double-click: open inline text input
|
||||||
|
const handleDoubleClick = useCallback((e) => {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
setEditText(String(typeof displayVal === 'number' ? displayVal : value));
|
||||||
|
setEditing(true);
|
||||||
|
// Focus input after render
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}, [value, displayVal]);
|
||||||
|
|
||||||
|
const commitEdit = useCallback(() => {
|
||||||
|
const parsed = parseFloat(editText);
|
||||||
|
if (!isNaN(parsed)) {
|
||||||
|
const clamped = Math.max(min, Math.min(max, parsed));
|
||||||
|
onChange(clamped);
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
}, [editText, min, max, onChange]);
|
||||||
|
|
||||||
|
const handleInputKeyDown = useCallback((e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
commitEdit();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
}, [commitEdit]);
|
||||||
|
|
||||||
|
const handleInputBlur = useCallback(() => {
|
||||||
|
commitEdit();
|
||||||
|
}, [commitEdit]);
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<div className="knob-container knob-editing" onWheel={(e) => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="knob-input"
|
||||||
|
type="text"
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="knob-container" onWheel={handleWheel}>
|
<div className="knob-container" onWheel={handleWheel} onDoubleClick={handleDoubleClick}>
|
||||||
<svg className="knob-svg" viewBox={`0 0 ${SIZE} ${SIZE}`}
|
<svg className="knob-svg" viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
onPointerDown={handlePointerDown} ref={ref}>
|
onPointerDown={handlePointerDown} ref={ref}>
|
||||||
<path className="knob-track" d={trackPath} />
|
<path className="knob-track" d={trackPath} />
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
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);
|
const [showStars, setShowStars] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Animate stars appearing one by one
|
|
||||||
const timers = [];
|
const timers = [];
|
||||||
for (let i = 1; i <= stars; i++) {
|
for (let i = 1; i <= stars; i++) {
|
||||||
timers.push(setTimeout(() => setShowStars(i), i * 400));
|
timers.push(setTimeout(() => setShowStars(i), i * 400));
|
||||||
@@ -13,9 +12,9 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet
|
|||||||
}, [stars]);
|
}, [stars]);
|
||||||
|
|
||||||
const messages = [
|
const messages = [
|
||||||
'', // 0 stars
|
'',
|
||||||
'Has dado el primer paso...',
|
'Has dado el primer paso...',
|
||||||
'Buen trabajo — casi perfecto.',
|
hintPenalty ? 'Reinicia sin pista para conseguir 3 estrellas.' : 'Buen trabajo — casi perfecto.',
|
||||||
'Ejecucion impecable.',
|
'Ejecucion impecable.',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -27,21 +26,25 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="gm-complete-level">{levelTitle}</p>
|
<p className="gm-complete-level">{levelTitle}</p>
|
||||||
|
|
||||||
{/* Stars */}
|
|
||||||
<div className="gm-complete-stars">
|
<div className="gm-complete-stars">
|
||||||
{[1, 2, 3].map(i => (
|
{[1, 2, 3].map(i => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
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>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="gm-complete-msg">{messages[stars] || ''}</p>
|
<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">
|
<div className="gm-checks">
|
||||||
{checks.map((check, i) => (
|
{checks.map((check, i) => (
|
||||||
<div key={i} className={`gm-check ${check.passed ? 'passed' : 'failed'}`}>
|
<div key={i} className={`gm-check ${check.passed ? 'passed' : 'failed'}`}>
|
||||||
@@ -52,10 +55,11 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="gm-complete-actions">
|
<div className="gm-complete-actions">
|
||||||
<button className="gm-btn secondary" onClick={onMap}>Mapa</button>
|
<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 && (
|
{stars >= 1 && !isLastLevel && (
|
||||||
<button className="gm-btn primary" onClick={onNext}>Siguiente →</button>
|
<button className="gm-btn primary" onClick={onNext}>Siguiente →</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,17 +14,16 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
const portPositions = useRef({});
|
const portPositions = useRef({});
|
||||||
const [tempWire, setTempWire] = useState(null);
|
const [tempWire, setTempWire] = useState(null);
|
||||||
const connectingRef = useRef(null);
|
const connectingRef = useRef(null);
|
||||||
const [showConcept, setShowConcept] = useState(true);
|
const [hintUsed, setHintUsed] = useState(false);
|
||||||
const [result, setResult] = useState(null); // { stars, checks }
|
const [showHint, setShowHint] = useState(false);
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
const [targetPlaying, setTargetPlaying] = useState(false);
|
const [targetPlaying, setTargetPlaying] = useState(false);
|
||||||
|
|
||||||
// Subscribe to state changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = subscribe(() => forceUpdate(n => n + 1));
|
const unsub = subscribe(() => forceUpdate(n => n + 1));
|
||||||
return unsub;
|
return unsub;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load level on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLevel();
|
loadLevel();
|
||||||
return () => {
|
return () => {
|
||||||
@@ -34,31 +33,25 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
}, [level.id]);
|
}, [level.id]);
|
||||||
|
|
||||||
const loadLevel = useCallback(() => {
|
const loadLevel = useCallback(() => {
|
||||||
// Clear state and load preplaced modules
|
|
||||||
const data = {
|
const data = {
|
||||||
modules: (level.preplacedModules || []).map(m => ({
|
modules: (level.preplacedModules || []).map(m => ({
|
||||||
id: m.id,
|
id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params },
|
||||||
type: m.type,
|
|
||||||
x: m.x,
|
|
||||||
y: m.y,
|
|
||||||
params: { ...m.params },
|
|
||||||
})),
|
})),
|
||||||
connections: [],
|
connections: [],
|
||||||
camera: { camX: 0, camY: 0, zoom: 1 },
|
camera: { camX: 0, camY: 0, zoom: 1 },
|
||||||
};
|
};
|
||||||
deserialize(data);
|
deserialize(data);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setShowConcept(true);
|
setHintUsed(false);
|
||||||
|
setShowHint(false);
|
||||||
if (state.isRunning) stopAudio();
|
if (state.isRunning) stopAudio();
|
||||||
}, [level]);
|
}, [level]);
|
||||||
|
|
||||||
// Port position reporting
|
|
||||||
const handlePortPosition = useCallback((moduleId, portName, direction, el) => {
|
const handlePortPosition = useCallback((moduleId, portName, direction, el) => {
|
||||||
const key = `${moduleId}-${portName}-${direction}`;
|
const key = `${moduleId}-${portName}-${direction}`;
|
||||||
portPositions.current[key] = el;
|
portPositions.current[key] = el;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Start connecting wire
|
|
||||||
const handleStartConnect = useCallback((info) => {
|
const handleStartConnect = useCallback((info) => {
|
||||||
connectingRef.current = info;
|
connectingRef.current = info;
|
||||||
const containerRect = containerRef.current.getBoundingClientRect();
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
@@ -71,14 +64,23 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const findPortAtPoint = (x, y) => {
|
// Robust port detection — searches all port-dots by bounding rect distance
|
||||||
for (const [dx, dy] of [[0,0],[-8,0],[8,0],[0,-8],[0,8],[-6,-6],[6,-6],[-6,6],[6,6]]) {
|
// instead of elementFromPoint (which gets blocked by SVG wire overlay)
|
||||||
const hit = document.elementFromPoint(x + dx, y + dy);
|
const findPortAtPoint = (clientX, clientY) => {
|
||||||
if (hit && hit.classList.contains('port-dot') && hit.dataset.moduleId) {
|
const portDots = document.querySelectorAll('.port-dot[data-module-id]');
|
||||||
return hit;
|
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) => {
|
const handlePointerDown = useCallback((e) => {
|
||||||
@@ -166,7 +168,6 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
|
|
||||||
const handleContextMenu = useCallback((e) => e.preventDefault(), []);
|
const handleContextMenu = useCallback((e) => e.preventDefault(), []);
|
||||||
|
|
||||||
// Add module from palette
|
|
||||||
const handleAddModule = (type) => {
|
const handleAddModule = (type) => {
|
||||||
const x = (-state.camX + 250) / state.zoom + Math.random() * 30;
|
const x = (-state.camX + 250) / state.zoom + Math.random() * 30;
|
||||||
const y = (-state.camY + 150) / 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();
|
if (state.isRunning) rebuildGraph();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle player audio
|
|
||||||
const handleToggleAudio = async () => {
|
const handleToggleAudio = async () => {
|
||||||
if (state.isRunning) {
|
if (state.isRunning) {
|
||||||
stopAudio();
|
stopAudio();
|
||||||
@@ -184,7 +184,6 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
emit();
|
emit();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Play target sound
|
|
||||||
const handlePlayTarget = async () => {
|
const handlePlayTarget = async () => {
|
||||||
if (isTargetPlaying()) {
|
if (isTargetPlaying()) {
|
||||||
stopTarget();
|
stopTarget();
|
||||||
@@ -192,12 +191,16 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
} else {
|
} else {
|
||||||
setTargetPlaying(true);
|
setTargetPlaying(true);
|
||||||
await playTarget(level.target);
|
await playTarget(level.target);
|
||||||
// Auto-update when target stops
|
|
||||||
setTimeout(() => setTargetPlaying(false), (level.target.duration || 2) * 1000 + 100);
|
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 handleCheck = () => {
|
||||||
const mods = state.modules;
|
const mods = state.modules;
|
||||||
const conns = state.connections;
|
const conns = state.connections;
|
||||||
@@ -206,14 +209,16 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
passed: check.test(mods, conns),
|
passed: check.test(mods, conns),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Stars: sequential — need all previous stars to earn next
|
|
||||||
let stars = 0;
|
let stars = 0;
|
||||||
for (const check of checks) {
|
for (const check of checks) {
|
||||||
if (check.passed) stars = check.star;
|
if (check.passed) stars = check.star;
|
||||||
else break;
|
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) {
|
if (stars >= 1) {
|
||||||
completeLevel(level.id, stars);
|
completeLevel(level.id, stars);
|
||||||
@@ -251,20 +256,33 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="gm-puzzle-content">
|
<div className="gm-puzzle-content">
|
||||||
{/* Left sidebar: concept + module palette */}
|
{/* Left sidebar */}
|
||||||
<div className="gm-puzzle-sidebar">
|
<div className="gm-puzzle-sidebar">
|
||||||
{/* Concept panel */}
|
{/* Description — always visible */}
|
||||||
<div className="gm-concept-panel">
|
<div className="gm-concept-panel">
|
||||||
<div className="gm-concept-header" onClick={() => setShowConcept(!showConcept)}>
|
<div className="gm-concept-header">
|
||||||
<span>💡 Concepto</span>
|
<span>📖 Mision</span>
|
||||||
<span>{showConcept ? '▼' : '▶'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{showConcept && (
|
<div className="gm-concept-body">
|
||||||
<div className="gm-concept-body">
|
<p className="gm-concept-desc">{level.description}</p>
|
||||||
<p className="gm-concept-desc">{level.description}</p>
|
</div>
|
||||||
<div className="gm-concept-tip">
|
</div>
|
||||||
<strong>Pista:</strong> {level.concept}
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
|
<p className="gm-hint-text">{level.concept}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -274,18 +292,27 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
<div className="gm-obj-title">Objetivos</div>
|
<div className="gm-obj-title">Objetivos</div>
|
||||||
{level.checks.map((check, i) => {
|
{level.checks.map((check, i) => {
|
||||||
const passed = result?.checks?.[i]?.passed;
|
const passed = result?.checks?.[i]?.passed;
|
||||||
|
const cappedByStar = hintUsed && check.star === 3;
|
||||||
return (
|
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-star">{'★'.repeat(check.star)}</span>
|
||||||
<span className="gm-obj-name">{check.desc}</span>
|
<span className="gm-obj-name">
|
||||||
{passed === true && <span className="gm-obj-check">✓</span>}
|
{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>}
|
{passed === false && <span className="gm-obj-x">✗</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{hintUsed && (
|
||||||
|
<div className="gm-hint-warning">
|
||||||
|
Pista usada — maximo 2 estrellas. Reinicia para intentar sin pista.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Module palette for this level */}
|
{/* Module palette */}
|
||||||
{level.availableModules.length > 0 && (
|
{level.availableModules.length > 0 && (
|
||||||
<div className="gm-module-palette">
|
<div className="gm-module-palette">
|
||||||
<div className="gm-palette-title">Modulos Disponibles</div>
|
<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);
|
const def = getModuleDef(type);
|
||||||
if (!def) return null;
|
if (!def) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={type} className="gm-palette-item" onClick={() => handleAddModule(type)}>
|
||||||
key={type}
|
|
||||||
className="gm-palette-item"
|
|
||||||
onClick={() => handleAddModule(type)}
|
|
||||||
>
|
|
||||||
<span className="gm-palette-icon">{def.icon}</span>
|
<span className="gm-palette-icon">{def.icon}</span>
|
||||||
<span className="gm-palette-name">{def.name}</span>
|
<span className="gm-palette-name">{def.name}</span>
|
||||||
<span className="gm-palette-add">+</span>
|
<span className="gm-palette-add">+</span>
|
||||||
@@ -307,7 +330,6 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reset button */}
|
|
||||||
<button className="gm-btn danger" onClick={loadLevel} style={{ marginTop: 'auto' }}>
|
<button className="gm-btn danger" onClick={loadLevel} style={{ marginTop: 'auto' }}>
|
||||||
↺ Reiniciar Nivel
|
↺ Reiniciar Nivel
|
||||||
</button>
|
</button>
|
||||||
@@ -324,7 +346,6 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
{/* Grid */}
|
|
||||||
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 0 }}>
|
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id="puzzle-grid" width={20 * state.zoom} height={20 * state.zoom}
|
<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)" />
|
<rect width="100%" height="100%" fill="url(#puzzle-grid)" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Wires */}
|
|
||||||
<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 */}
|
|
||||||
<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
|
||||||
@@ -353,7 +372,6 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Canvas hints */}
|
|
||||||
{state.modules.length > 0 && state.connections.length === 0 && (
|
{state.modules.length > 0 && state.connections.length === 0 && (
|
||||||
<div className="gm-canvas-hint">
|
<div className="gm-canvas-hint">
|
||||||
Arrastra de un puerto (circulo) a otro para conectar modulos
|
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}
|
checks={result.checks}
|
||||||
levelTitle={level.title}
|
levelTitle={level.title}
|
||||||
isLastLevel={isLastLevel}
|
isLastLevel={isLastLevel}
|
||||||
|
hintPenalty={result.hintPenalty}
|
||||||
onRetry={loadLevel}
|
onRetry={loadLevel}
|
||||||
onMap={onBack}
|
onMap={onBack}
|
||||||
onNext={onNextLevel}
|
onNext={onNextLevel}
|
||||||
|
|||||||
@@ -168,6 +168,15 @@ html, body, #root {
|
|||||||
.knob-fill { fill: none; stroke-width: 3; stroke-linecap: round; }
|
.knob-fill { fill: none; stroke-width: 3; stroke-linecap: round; }
|
||||||
.knob-dot { fill: var(--text); }
|
.knob-dot { fill: var(--text); }
|
||||||
|
|
||||||
|
.knob-editing { display: flex; align-items: center; justify-content: center; }
|
||||||
|
.knob-input {
|
||||||
|
width: 48px; height: 22px; padding: 0 4px;
|
||||||
|
background: var(--bg); border: 1px solid var(--accent); border-radius: 3px;
|
||||||
|
color: var(--accent); font-size: 11px; font-family: 'JetBrains Mono', monospace;
|
||||||
|
text-align: center; outline: none;
|
||||||
|
}
|
||||||
|
.knob-input:focus { box-shadow: 0 0 6px rgba(0,229,255,0.3); }
|
||||||
|
|
||||||
.param-value {
|
.param-value {
|
||||||
font-size: 10px; color: var(--accent); font-family: 'JetBrains Mono', monospace;
|
font-size: 10px; color: var(--accent); font-family: 'JetBrains Mono', monospace;
|
||||||
min-width: 40px; text-align: right;
|
min-width: 40px; text-align: right;
|
||||||
@@ -421,6 +430,54 @@ html, body, #root {
|
|||||||
.gm-obj.failed .gm-obj-name { color: var(--text2); }
|
.gm-obj.failed .gm-obj-name { color: var(--text2); }
|
||||||
.gm-obj-check { color: var(--green); font-weight: 700; }
|
.gm-obj-check { color: var(--green); font-weight: 700; }
|
||||||
.gm-obj-x { color: var(--red); 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) */
|
/* Module palette (game) */
|
||||||
.gm-module-palette {
|
.gm-module-palette {
|
||||||
|
|||||||
Reference in New Issue
Block a user