feat: circuit builder, electronics lab, SPICE simulator, expanded skill tree

Workbenches:
- Circuit Builder: drag-and-drop logic gates, wire connections, truth table verification, fullscreen mode
- Electronics Lab: SPICE-like DC simulator with MNA solver, voltage sources, resistors, capacitors, LEDs, switches, NMOS/PMOS transistors, voltmeter, ammeter, play/pause simulation, fullscreen mode
- Explanation renderer: auto-detects ASCII truth tables and renders them as styled HTML

Skill tree:
- 65+ nodes across 19 groups spanning math → electronics → CPU → ASM → OS → networking → web
- Groups: Aritmética, Álgebra, Lógica, Electrónica, Circuitos Digitales, Secuenciales, Tu CPU, Verilog/HDL, Arquitectura Extendida, Sistemas Operativos, Programación en C, Redes, La Web, Señales, Síntesis Audio, Gráficos, Tu Consola
- Dependency highlighting: clicking a node dims all others and highlights the full path
- Group boxes with colored borders around related nodes
- Dependency chain audit: fixed illogical prerequisites throughout the tree

Content:
- 24 electronics challenges (basics, series/parallel, capacitors, diodes, transistors, op-amps, power supplies)
- 12 circuit builder challenges (logic gates, NAND universality, combinational circuits)
- Fixed all explanation spoilers: examples now use different numbers than the challenge questions
- Probe system now requires voltmeter/ammeter instruments instead of checking arbitrary node IDs

UX:
- Custom dark-themed scrollbars
- Fullscreen mode for circuit/electronics editors (portal-based, Esc to exit)
- SVG coordinate fix using getScreenCTM for accurate wire placement in fullscreen
- Meter reading labels positioned correctly regardless of component rotation
- Scratchpad defaults to closed, persists open/close state in localStorage
- Empty placeholder nodes show "Próximamente" instead of appearing completed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose Luis Montañes
2026-03-26 03:50:07 +01:00
parent f9f74d3f19
commit 8d8a811ede
24 changed files with 4263 additions and 100 deletions

View File

@@ -129,6 +129,30 @@
} }
} }
/* Custom scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
/* React Flow dark mode overrides */ /* React Flow dark mode overrides */
.react-flow__controls { .react-flow__controls {
background: var(--card) !important; background: var(--card) !important;
@@ -176,6 +200,16 @@
animation: shake 0.5s ease-in-out; animation: shake 0.5s ease-in-out;
} }
/* Fullscreen fade in */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fade-in {
animation: fadeIn 0.2s ease-out;
}
/* Ensure handles are visible and edges connect properly */ /* Ensure handles are visible and edges connect properly */
.react-flow__handle { .react-flow__handle {
opacity: 0; opacity: 0;

View File

@@ -0,0 +1,37 @@
'use client';
import { memo } from 'react';
import { type Node, type NodeProps } from '@xyflow/react';
type SkillGroupData = {
label: string;
icon: string;
color: string;
width: number;
height: number;
} & Record<string, unknown>;
type SkillGroupType = Node<SkillGroupData>;
export const SkillGroupNode = memo(function SkillGroupNode({
data,
}: NodeProps<SkillGroupType>) {
return (
<div
style={{
width: data.width,
height: data.height,
borderColor: data.color,
}}
className="rounded-2xl border border-dashed bg-gradient-to-b from-white/[0.02] to-transparent pointer-events-none"
>
<div
className="absolute -top-0 left-4 px-3 py-1 rounded-b-lg text-xs font-bold tracking-wide flex items-center gap-1.5"
style={{ backgroundColor: data.color + '18', color: data.color }}
>
<span>{data.icon}</span>
{data.label}
</div>
</div>
);
});

View File

@@ -5,7 +5,11 @@ import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
import { Lock } from 'lucide-react'; import { Lock } from 'lucide-react';
import type { SkillNode, NodeStatus } from '@/types/skill-tree'; import type { SkillNode, NodeStatus } from '@/types/skill-tree';
type SkillNodeData = SkillNode & { status: NodeStatus } & Record<string, unknown>; type SkillNodeData = SkillNode & {
status: NodeStatus;
dimmed?: boolean;
highlighted?: boolean;
} & Record<string, unknown>;
type SkillNodeType = Node<SkillNodeData>; type SkillNodeType = Node<SkillNodeData>;
const statusStyles: Record<NodeStatus, string> = { const statusStyles: Record<NodeStatus, string> = {
@@ -29,10 +33,14 @@ export const SkillNodeComponent = memo(function SkillNodeComponent({
}: NodeProps<SkillNodeType>) { }: NodeProps<SkillNodeType>) {
const status = data.status; const status = data.status;
const badge = statusBadge[status]; const badge = statusBadge[status];
const dimmed = data.dimmed;
const highlighted = data.highlighted;
return ( return (
<div <div
className={`px-4 py-3 rounded-xl border-2 transition-all duration-200 min-w-[140px] max-w-[180px] ${statusStyles[status]}`} className={`px-4 py-3 rounded-xl border-2 transition-all duration-300 min-w-[140px] max-w-[180px] ${statusStyles[status]} ${
dimmed ? '!opacity-15 scale-95' : ''
} ${highlighted ? '!border-primary ring-2 ring-primary/30 scale-105' : ''}`}
> >
<Handle type="target" position={Position.Top} className="!bg-muted-foreground/30 !w-2 !h-2 !border-0" /> <Handle type="target" position={Position.Top} className="!bg-muted-foreground/30 !w-2 !h-2 !border-0" />

View File

@@ -1,31 +1,61 @@
'use client'; 'use client';
import { useState, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { X, Star, CheckCircle2, Lock, ArrowRight } from 'lucide-react'; import { X, Star, CheckCircle2, Lock, ArrowRight, ArrowDown, GitBranch } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { getNodeById } from '@/data/skill-tree'; import { skillNodes, getNodeById } from '@/data/skill-tree';
import { getChallengesForNode } from '@/data/challenges/math'; import { getChallengesForNode } from '@/data/challenges/math';
import { useProgressStore } from '@/stores/useProgressStore'; import { useProgressStore } from '@/stores/useProgressStore';
import type { SkillNode } from '@/types/skill-tree';
interface SkillNodeDetailProps { interface SkillNodeDetailProps {
nodeId: string; nodeId: string;
onClose: () => void; onClose: () => void;
} }
type Tab = 'challenges' | 'dependencies';
/**
* Walk the prerequisite graph backwards to build the full dependency chain.
* Returns nodes in topological order (root first → target last).
*/
function getDependencyChain(targetId: string): SkillNode[] {
const visited = new Set<string>();
const chain: SkillNode[] = [];
function walk(id: string) {
if (visited.has(id)) return;
visited.add(id);
const node = getNodeById(id);
if (!node) return;
for (const prereq of node.prerequisites) {
walk(prereq);
}
chain.push(node);
}
walk(targetId);
// Remove the target itself — we only want the path TO it
return chain.filter((n) => n.id !== targetId);
}
export function SkillNodeDetail({ nodeId, onClose }: SkillNodeDetailProps) { export function SkillNodeDetail({ nodeId, onClose }: SkillNodeDetailProps) {
const router = useRouter(); const router = useRouter();
const node = getNodeById(nodeId); const node = getNodeById(nodeId);
const getNodeStatus = useProgressStore((s) => s.getNodeStatus); const getNodeStatus = useProgressStore((s) => s.getNodeStatus);
const completedChallenges = useProgressStore((s) => s.completedChallenges); const completedChallenges = useProgressStore((s) => s.completedChallenges);
const [tab, setTab] = useState<Tab>('challenges');
if (!node) return null; if (!node) return null;
const status = getNodeStatus(nodeId); const status = getNodeStatus(nodeId);
const challenges = getChallengesForNode(nodeId); const challenges = getChallengesForNode(nodeId);
const completedCount = challenges.filter((c) => completedChallenges[c.id]).length; const completedCount = challenges.filter((c) => completedChallenges[c.id]).length;
const depChain = useMemo(() => getDependencyChain(nodeId), [nodeId]);
return ( return (
<div className="absolute top-0 right-0 h-full w-full sm:w-96 bg-card border-l border-border shadow-2xl z-50 flex flex-col"> <div className="absolute top-0 right-0 h-full w-full sm:w-96 bg-card border-l border-border shadow-2xl z-50 flex flex-col">
@@ -74,8 +104,34 @@ export function SkillNodeDetail({ nodeId, onClose }: SkillNodeDetailProps) {
</div> </div>
</div> </div>
{/* Challenges list */} {/* Tabs */}
<ScrollArea className="flex-1"> <div className="flex border-b border-border">
<button
onClick={() => setTab('challenges')}
className={`flex-1 px-4 py-2.5 text-xs font-medium transition-colors ${
tab === 'challenges'
? 'text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Retos ({challenges.length})
</button>
<button
onClick={() => setTab('dependencies')}
className={`flex-1 px-4 py-2.5 text-xs font-medium transition-colors flex items-center justify-center gap-1.5 ${
tab === 'dependencies'
? 'text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<GitBranch className="w-3 h-3" />
Dependencias ({depChain.length})
</button>
</div>
{/* Tab content */}
<ScrollArea className="flex-1 min-h-0">
{tab === 'challenges' && (
<div className="p-4 space-y-2"> <div className="p-4 space-y-2">
{status === 'locked' ? ( {status === 'locked' ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
@@ -85,6 +141,13 @@ export function SkillNodeDetail({ nodeId, onClose }: SkillNodeDetailProps) {
Completa los prerrequisitos para desbloquear Completa los prerrequisitos para desbloquear
</p> </p>
</div> </div>
) : challenges.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm font-medium">Próximamente</p>
<p className="text-xs mt-1 text-center">
Los retos de esta habilidad aún no están disponibles
</p>
</div>
) : ( ) : (
challenges.map((challenge, index) => { challenges.map((challenge, index) => {
const isCompleted = !!completedChallenges[challenge.id]; const isCompleted = !!completedChallenges[challenge.id];
@@ -129,6 +192,96 @@ export function SkillNodeDetail({ nodeId, onClose }: SkillNodeDetailProps) {
}) })
)} )}
</div> </div>
)}
{tab === 'dependencies' && (
<div className="p-4">
{depChain.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm font-medium">Sin dependencias</p>
<p className="text-xs mt-1 text-center">
Esta es una habilidad raíz
</p>
</div>
) : (
<div className="space-y-0">
{depChain.map((dep, index) => {
const depStatus = getNodeStatus(dep.id);
const isCompleted = depStatus === 'completed';
const isAvailable = depStatus === 'available' || depStatus === 'in-progress';
return (
<div key={dep.id}>
<div
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
isCompleted ? 'opacity-70' : ''
}`}
>
{/* Step number / status */}
<div
className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold shrink-0 ${
isCompleted
? 'bg-green-500/20 text-green-500'
: isAvailable
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}
>
{isCompleted ? (
<CheckCircle2 className="w-3.5 h-3.5" />
) : (
index + 1
)}
</div>
{/* Node info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm">{dep.icon}</span>
<p className="text-sm font-medium truncate">{dep.title}</p>
</div>
<p className="text-[10px] text-muted-foreground">{dep.discipline}</p>
</div>
{/* Status badge */}
{isCompleted && (
<span className="text-[10px] text-green-500 font-medium shrink-0">Hecho</span>
)}
{isAvailable && (
<span className="text-[10px] text-primary font-medium shrink-0">Disponible</span>
)}
{!isCompleted && !isAvailable && (
<Lock className="w-3 h-3 text-muted-foreground/50 shrink-0" />
)}
</div>
{/* Connector arrow */}
{index < depChain.length - 1 && (
<div className="flex justify-start pl-6">
<ArrowDown className="w-3 h-3 text-muted-foreground/30" />
</div>
)}
</div>
);
})}
{/* Final arrow to current node */}
<div className="flex justify-start pl-6">
<ArrowDown className="w-3 h-3 text-muted-foreground/30" />
</div>
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary/5 border border-primary/20">
<div className="w-7 h-7 rounded-full flex items-center justify-center bg-primary/20 shrink-0">
<span className="text-sm">{node.icon}</span>
</div>
<div>
<p className="text-sm font-bold">{node.title}</p>
<p className="text-[10px] text-primary"> Estás aquí</p>
</div>
</div>
</div>
)}
</div>
)}
</ScrollArea> </ScrollArea>
</div> </div>
); );

View File

@@ -12,26 +12,213 @@ import {
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import Dagre from '@dagrejs/dagre'; import Dagre from '@dagrejs/dagre';
import { skillNodes, skillEdges } from '@/data/skill-tree'; import { skillNodes, skillEdges, getNodeById } from '@/data/skill-tree';
import { useProgressStore } from '@/stores/useProgressStore'; import { useProgressStore } from '@/stores/useProgressStore';
import { SkillNodeComponent } from './SkillNode'; import { SkillNodeComponent } from './SkillNode';
import { SkillGroupNode } from './SkillGroupNode';
import { SkillNodeDetail } from './SkillNodeDetail'; import { SkillNodeDetail } from './SkillNodeDetail';
/** Get all ancestor node IDs (full dependency chain) for a given node */
function getDependencyIds(targetId: string): Set<string> {
const visited = new Set<string>();
function walk(id: string) {
if (visited.has(id)) return;
visited.add(id);
const node = getNodeById(id);
if (!node) return;
for (const prereq of node.prerequisites) {
walk(prereq);
}
}
walk(targetId);
visited.delete(targetId); // don't include the target itself
return visited;
}
const nodeTypes = { const nodeTypes = {
skillNode: SkillNodeComponent, skillNode: SkillNodeComponent,
skillGroup: SkillGroupNode,
}; };
const NODE_WIDTH = 170; const NODE_WIDTH = 170;
const NODE_HEIGHT = 70; const NODE_HEIGHT = 70;
const GROUP_PADDING = 40;
// Define visual groups: which node IDs belong to each group
const SKILL_GROUPS: {
id: string;
label: string;
icon: string;
color: string;
nodeIds: string[];
}[] = [
{
id: 'group-arithmetic',
label: 'Aritmética',
icon: '🔢',
color: '#3b82f6',
nodeIds: [
'arithmetic.addition', 'arithmetic.subtraction', 'arithmetic.multiplication',
'arithmetic.division', 'arithmetic.fractions', 'arithmetic.decimals',
'arithmetic.percentages', 'number-theory.primes', 'number-theory.gcd-lcm',
],
},
{
id: 'group-algebra',
label: 'Álgebra',
icon: '📐',
color: '#a855f7',
nodeIds: [
'algebra.variables', 'algebra.equations', 'algebra.linear-systems', 'algebra.quadratics',
],
},
{
id: 'group-logic',
label: 'Lógica y Computación',
icon: '🧠',
color: '#f59e0b',
nodeIds: [
'logic.boolean', 'logic.binary',
],
},
{
id: 'group-electronics',
label: 'Electrónica',
icon: '🔋',
color: '#ef4444',
nodeIds: [
'electronics.basics', 'electronics.series-parallel', 'electronics.capacitors',
'electronics.diodes', 'electronics.transistors', 'electronics.opamp', 'electronics.power',
],
},
{
id: 'group-circuits',
label: 'Circuitos Digitales',
icon: '⚡',
color: '#22c55e',
nodeIds: [
'electronics.logic-gates', 'electronics.combinational',
],
},
{
id: 'group-sequential',
label: 'Circuitos Secuenciales',
icon: '🔄',
color: '#06b6d4',
nodeIds: [
'sequential.flipflops', 'sequential.registers', 'sequential.counters',
],
},
{
id: 'group-cpu',
label: 'Tu CPU',
icon: '🖥️',
color: '#8b5cf6',
nodeIds: [
'cpu.alu', 'cpu.memory', 'cpu.control-isa', 'cpu.complete',
'asm.stack-subroutines', 'asm.interrupts',
],
},
{
id: 'group-signals',
label: 'Procesamiento de Señales',
icon: '🌊',
color: '#14b8a6',
nodeIds: [
'signals.trigonometry', 'signals.waves', 'signals.sampling',
],
},
{
id: 'group-audio',
label: 'Síntesis de Audio',
icon: '🎹',
color: '#f97316',
nodeIds: [
'audio.oscillators', 'audio.filters', 'audio.synthesizer', 'audio.sound-card',
],
},
{
id: 'group-graphics',
label: 'Gráficos',
icon: '👾',
color: '#84cc16',
nodeIds: [
'graphics.pixels', 'graphics.framebuffer', 'graphics.sprites', 'graphics.tilemaps', 'graphics.gpu',
],
},
{
id: 'group-game',
label: 'Tu Consola',
icon: '🎮',
color: '#eab308',
nodeIds: [
'game.input', 'game.gameloop', 'game.collision', 'game.sound-integration', 'game.final',
],
},
{
id: 'group-hdl',
label: 'Verilog / HDL',
icon: '📝',
color: '#f472b6',
nodeIds: [
'hdl.basics', 'hdl.testbench', 'hdl.cpu-verilog',
],
},
{
id: 'group-ext-arch',
label: 'Arquitectura Extendida',
icon: '🏗️',
color: '#7c3aed',
nodeIds: [
'ext-arch.16bit', 'ext-arch.32bit', 'ext-arch.mmu', 'ext-arch.peripherals',
],
},
{
id: 'group-os',
label: 'Sistemas Operativos',
icon: '🧬',
color: '#0ea5e9',
nodeIds: [
'os.bootloader', 'os.kernel', 'os.processes', 'os.filesystem', 'os.drivers',
],
},
{
id: 'group-hlp',
label: 'Programación en C',
icon: '©️',
color: '#d946ef',
nodeIds: [
'hlp.c-basics', 'hlp.pointers-memory', 'hlp.data-structures', 'hlp.algorithms', 'hlp.compiler',
],
},
{
id: 'group-net',
label: 'Redes (Modelo OSI)',
icon: '🌐',
color: '#06b6d4',
nodeIds: [
'net.physical', 'net.ip', 'net.tcp-udp', 'net.sockets', 'net.dns',
],
},
{
id: 'group-web',
label: 'La Web',
icon: '🖧',
color: '#10b981',
nodeIds: [
'web.http', 'web.server', 'web.html-css', 'web.client', 'web.final',
],
},
];
function getLayoutedElements(nodes: Node[], edges: Edge[]) { function getLayoutedElements(nodes: Node[], edges: Edge[]) {
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
g.setGraph({ g.setGraph({
rankdir: 'TB', // top to bottom rankdir: 'TB',
nodesep: 80, // horizontal spacing between nodes nodesep: 80,
ranksep: 120, // vertical spacing between ranks ranksep: 120,
edgesep: 40, // minimum spacing between edges edgesep: 40,
marginx: 40, marginx: 40,
marginy: 40, marginy: 40,
}); });
@@ -62,6 +249,41 @@ function getLayoutedElements(nodes: Node[], edges: Edge[]) {
return { nodes: layoutedNodes, edges }; return { nodes: layoutedNodes, edges };
} }
/** Compute bounding box group nodes from layouted skill nodes */
function computeGroupNodes(layoutedNodes: Node[]): Node[] {
return SKILL_GROUPS.map((group) => {
const memberNodes = layoutedNodes.filter((n) => group.nodeIds.includes(n.id));
if (memberNodes.length === 0) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of memberNodes) {
minX = Math.min(minX, n.position.x);
minY = Math.min(minY, n.position.y);
maxX = Math.max(maxX, n.position.x + NODE_WIDTH);
maxY = Math.max(maxY, n.position.y + NODE_HEIGHT);
}
const width = maxX - minX + GROUP_PADDING * 2;
const height = maxY - minY + GROUP_PADDING * 2;
return {
id: group.id,
type: 'skillGroup',
position: { x: minX - GROUP_PADDING, y: minY - GROUP_PADDING },
draggable: false,
selectable: false,
style: { zIndex: -1 },
data: {
label: group.label,
icon: group.icon,
color: group.color,
width,
height,
},
} satisfies Node;
}).filter(Boolean) as Node[];
}
export function SkillTreeCanvas() { export function SkillTreeCanvas() {
const getNodeStatus = useProgressStore((s) => s.getNodeStatus); const getNodeStatus = useProgressStore((s) => s.getNodeStatus);
const completedChallenges = useProgressStore((s) => s.completedChallenges); const completedChallenges = useProgressStore((s) => s.completedChallenges);
@@ -72,23 +294,46 @@ export function SkillTreeCanvas() {
setMounted(true); setMounted(true);
}, []); }, []);
// Dependency chain for the selected node
const depIds = useMemo(() => {
if (!selectedNodeId) return null;
const deps = getDependencyIds(selectedNodeId);
deps.add(selectedNodeId); // include the selected node itself in the highlighted set
return deps;
}, [selectedNodeId]);
const { nodes, edges } = useMemo(() => { const { nodes, edges } = useMemo(() => {
const rawNodes: Node[] = skillNodes.map((node) => ({ const rawNodes: Node[] = skillNodes.map((node) => {
const isInPath = depIds ? depIds.has(node.id) : false;
const isSelected = node.id === selectedNodeId;
return {
id: node.id, id: node.id,
type: 'skillNode', type: 'skillNode',
position: { x: 0, y: 0 }, // dagre will override position: { x: 0, y: 0 },
draggable: false, draggable: false,
data: { data: {
...node, ...node,
status: getNodeStatus(node.id), status: getNodeStatus(node.id),
dimmed: depIds ? !isInPath : false,
highlighted: isSelected,
}, },
})); };
});
const rawEdges: Edge[] = skillEdges.map((edge) => { const rawEdges: Edge[] = skillEdges.map((edge) => {
const targetStatus = getNodeStatus(edge.to); const targetStatus = getNodeStatus(edge.to);
const sourceStatus = getNodeStatus(edge.from); const sourceStatus = getNodeStatus(edge.from);
// Is this edge part of the dependency path?
const edgeInPath = depIds ? depIds.has(edge.from) && depIds.has(edge.to) : false;
const edgeDimmed = depIds ? !edgeInPath : false;
let strokeColor = '#444'; let strokeColor = '#444';
if (sourceStatus === 'completed' && targetStatus !== 'locked') { if (edgeInPath) {
strokeColor = '#6366f1'; // highlight path edges in primary
} else if (edgeDimmed) {
strokeColor = '#222';
} else if (sourceStatus === 'completed' && targetStatus !== 'locked') {
strokeColor = '#22c55e'; strokeColor = '#22c55e';
} else if (targetStatus === 'available') { } else if (targetStatus === 'available') {
strokeColor = '#6366f1'; strokeColor = '#6366f1';
@@ -101,20 +346,37 @@ export function SkillTreeCanvas() {
source: edge.from, source: edge.from,
target: edge.to, target: edge.to,
type: 'bezier', type: 'bezier',
animated: targetStatus === 'available', animated: edgeInPath || (!depIds && targetStatus === 'available'),
style: { style: {
stroke: strokeColor, stroke: strokeColor,
strokeWidth: 2, strokeWidth: edgeInPath ? 3 : 2,
opacity: edgeDimmed ? 0.15 : 1,
transition: 'all 0.3s ease',
}, },
}; };
}); });
return getLayoutedElements(rawNodes, rawEdges); const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(rawNodes, rawEdges);
// Create group background nodes from the layouted positions
const groupNodes = computeGroupNodes(layoutedNodes);
// Groups go first (rendered behind), then skill nodes on top
return {
nodes: [...groupNodes, ...layoutedNodes],
edges: layoutedEdges,
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [getNodeStatus, completedChallenges]); }, [getNodeStatus, completedChallenges, depIds, selectedNodeId]);
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
setSelectedNodeId(node.id); if (node.type === 'skillGroup') return;
// Toggle: click same node deselects
setSelectedNodeId((prev) => prev === node.id ? null : node.id);
}, []);
const onPaneClick = useCallback(() => {
setSelectedNodeId(null);
}, []); }, []);
if (!mounted) { if (!mounted) {
@@ -131,10 +393,11 @@ export function SkillTreeCanvas() {
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
fitView fitView
fitViewOptions={{ padding: 0.4 }} fitViewOptions={{ padding: 0.4 }}
minZoom={0.3} minZoom={0.2}
maxZoom={1.5} maxZoom={1.5}
nodesDraggable={false} nodesDraggable={false}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}

View File

@@ -0,0 +1,102 @@
'use client';
/**
* Renders explanation text, detecting ASCII truth tables and rendering them
* as styled HTML tables.
*
* Detects lines with "|" separators as table rows.
* A group of consecutive "|" lines becomes one table.
*/
export function ExplanationRenderer({ text }: { text: string }) {
const lines = text.split('\n');
const blocks: Array<{ type: 'text'; content: string } | { type: 'table'; rows: string[][] }> = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Detect table: line contains | and has content on both sides
if (isTableLine(line)) {
const tableRows: string[][] = [];
while (i < lines.length && isTableLine(lines[i])) {
const cells = lines[i]
.split('|')
.map((c) => c.trim())
.filter((c) => c.length > 0);
tableRows.push(cells);
i++;
}
blocks.push({ type: 'table', rows: tableRows });
} else {
// Accumulate text lines
const textLines: string[] = [];
while (i < lines.length && !isTableLine(lines[i])) {
textLines.push(lines[i]);
i++;
}
const content = textLines.join('\n').trim();
if (content) blocks.push({ type: 'text', content });
}
}
return (
<div className="space-y-3">
{blocks.map((block, idx) => {
if (block.type === 'text') {
return (
<p key={idx} className="text-sm leading-relaxed whitespace-pre-line text-foreground/85">
{block.content}
</p>
);
}
// Render table
const [header, ...body] = block.rows;
const hasHeader = header && body.length > 0;
return (
<div key={idx} className="my-3 rounded-lg border border-border overflow-hidden inline-block">
<table className="text-xs font-mono">
{hasHeader && (
<thead>
<tr className="bg-muted/60">
{header.map((cell, j) => (
<th key={j} className="px-4 py-1.5 text-center font-bold text-muted-foreground">
{cell}
</th>
))}
</tr>
</thead>
)}
<tbody>
{(hasHeader ? body : block.rows).map((row, ri) => (
<tr key={ri} className="border-t border-border">
{row.map((cell, ci) => (
<td key={ci} className={`px-4 py-1 text-center ${
cell === '1' ? 'text-green-400 font-bold' :
cell === '0' ? 'text-muted-foreground' :
'text-foreground/85'
}`}>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
})}
</div>
);
}
function isTableLine(line: string): boolean {
if (!line) return false;
const trimmed = line.trim();
// Must contain | and have actual content (not just dashes/separators)
if (!trimmed.includes('|')) return false;
// Filter out lines that are just separators like "---|---|---"
const withoutSep = trimmed.replace(/[-|+\s]/g, '');
return withoutSep.length > 0;
}

View File

@@ -11,7 +11,10 @@ import { useProgressStore } from '@/stores/useProgressStore';
import { getChallengesForNode } from '@/data/challenges/math'; import { getChallengesForNode } from '@/data/challenges/math';
import { MathInput } from './modules/MathInput'; import { MathInput } from './modules/MathInput';
import { MultipleChoice } from './modules/MultipleChoice'; import { MultipleChoice } from './modules/MultipleChoice';
import { CircuitBuilder } from './modules/circuit-builder/CircuitBuilder';
import { ElectronicsLab } from './modules/electronics/ElectronicsLab';
import { Scratchpad } from './Scratchpad'; import { Scratchpad } from './Scratchpad';
import { ExplanationRenderer } from './ExplanationRenderer';
const MAX_ATTEMPTS_BEFORE_REVEAL = 3; const MAX_ATTEMPTS_BEFORE_REVEAL = 3;
@@ -42,7 +45,14 @@ export function WorkbenchShell({ challenge }: WorkbenchShellProps) {
const [elapsedTime, setElapsedTime] = useState(0); const [elapsedTime, setElapsedTime] = useState(0);
const [canProceed, setCanProceed] = useState(false); const [canProceed, setCanProceed] = useState(false);
const shakeTimeout = useRef<ReturnType<typeof setTimeout>>(null); const shakeTimeout = useRef<ReturnType<typeof setTimeout>>(null);
const [showScratchpad, setShowScratchpad] = useState(true); const [showScratchpad, setShowScratchpad] = useState(() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('scratchpad-open') === 'true';
});
useEffect(() => {
localStorage.setItem('scratchpad-open', showScratchpad ? 'true' : 'false');
}, [showScratchpad]);
const [showExplanation, setShowExplanation] = useState(!!challenge.explanation); const [showExplanation, setShowExplanation] = useState(!!challenge.explanation);
const isAlreadyCompleted = !!completedChallenges[challenge.id]; const isAlreadyCompleted = !!completedChallenges[challenge.id];
@@ -254,9 +264,7 @@ export function WorkbenchShell({ challenge }: WorkbenchShellProps) {
</button> </button>
{showExplanation && ( {showExplanation && (
<div className="px-6 pb-5 pt-0"> <div className="px-6 pb-5 pt-0">
<div className="text-sm leading-relaxed whitespace-pre-line text-foreground/85"> <ExplanationRenderer text={challenge.explanation!} />
{challenge.explanation}
</div>
</div> </div>
)} )}
</Card> </Card>
@@ -287,6 +295,20 @@ export function WorkbenchShell({ challenge }: WorkbenchShellProps) {
disabled={done || phase === 'wrong-shake'} disabled={done || phase === 'wrong-shake'}
/> />
)} )}
{challenge.content.type === 'circuit-builder' && (
<CircuitBuilder
content={challenge.content}
onCircuitChange={(circuit) => setAnswer(JSON.stringify(circuit))}
disabled={done || phase === 'wrong-shake'}
/>
)}
{challenge.content.type === 'electronics-lab' && (
<ElectronicsLab
content={challenge.content}
onCircuitChange={(circuit) => setAnswer(JSON.stringify(circuit))}
disabled={done || phase === 'wrong-shake'}
/>
)}
{/* Wrong attempt feedback inline */} {/* Wrong attempt feedback inline */}
{phase === 'wrong-shake' && ( {phase === 'wrong-shake' && (
<p className="text-sm text-red-400 mt-3 text-center"> <p className="text-sm text-red-400 mt-3 text-center">

View File

@@ -0,0 +1,377 @@
'use client';
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { CircuitBuilderContent, CircuitState, GateType, PlacedGate, Wire, gateInputCount } from '@/types/circuit';
import { GateComponent, GATE_W, GATE_H, getInputPortPositions, getOutputPortPosition } from './GateComponent';
import { simulateCircuit } from './simulateCircuit';
import { Trash2, Maximize2, Minimize2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface CircuitBuilderProps {
content: CircuitBuilderContent;
onCircuitChange: (circuit: CircuitState) => void;
disabled?: boolean;
}
const CANVAS_W = 700;
const CANVAS_H = 500;
const INPUT_X = 30;
const OUTPUT_X = CANVAS_W - 30;
interface PortPos {
id: string;
x: number;
y: number;
}
let nextGateId = 1;
export function CircuitBuilder({ content, onCircuitChange, disabled }: CircuitBuilderProps) {
const [gates, setGates] = useState<PlacedGate[]>([]);
const [wires, setWires] = useState<Wire[]>([]);
const [selectedGate, setSelectedGate] = useState<string | null>(null);
const [placingGate, setPlacingGate] = useState<GateType | null>(null);
const [wiringFrom, setWiringFrom] = useState<PortPos | null>(null);
const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null);
const [dragging, setDragging] = useState<{ id: string; offsetX: number; offsetY: number } | null>(null);
const [fullscreen, setFullscreen] = useState(false);
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (!fullscreen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setFullscreen(false); }
};
window.addEventListener('keydown', handleKey, true);
return () => window.removeEventListener('keydown', handleKey, true);
}, [fullscreen]);
const circuit: CircuitState = useMemo(() => ({ gates, wires }), [gates, wires]);
// Port positions for circuit inputs/outputs
const inputPorts: PortPos[] = content.inputLabels.map((_, i) => ({
id: `input-${i}`,
x: INPUT_X,
y: 80 + i * 80,
}));
const outputPorts: PortPos[] = content.outputLabels.map((_, i) => ({
id: `output-${i}`,
x: OUTPUT_X,
y: 80 + i * 80,
}));
// Get all port positions for wire rendering
const allPortPositions = useMemo(() => {
const map = new Map<string, { x: number; y: number }>();
inputPorts.forEach((p) => map.set(p.id, { x: p.x, y: p.y }));
outputPorts.forEach((p) => map.set(p.id, { x: p.x, y: p.y }));
gates.forEach((gate) => {
const inPorts = getInputPortPositions(gate.type);
inPorts.forEach((pos, i) => map.set(`${gate.id}:in-${i}`, { x: gate.x + pos.x, y: gate.y + pos.y }));
const outPos = getOutputPortPosition();
map.set(`${gate.id}:out`, { x: gate.x + outPos.x, y: gate.y + outPos.y });
});
return map;
}, [gates, inputPorts, outputPorts]);
// Simulate with all-false to show signal flow
const simResults = useMemo(() => {
const results: Map<string, boolean> = new Map();
// Simulate each truth table row to check pass/fail
return results;
}, []);
// Truth table evaluation
const truthTableResults = useMemo(() => {
return content.truthTable.map((row) => {
const result = simulateCircuit(circuit, row.inputs);
if (typeof result === 'string') return { expected: row.outputs, actual: null, pass: false };
const pass = result.every((v, i) => v === row.outputs[i]);
return { expected: row.outputs, actual: result, pass };
});
}, [circuit, content.truthTable]);
const getSvgPoint = useCallback((e: React.MouseEvent) => {
const svg = svgRef.current;
if (!svg) return { x: 0, y: 0 };
const ctm = svg.getScreenCTM();
if (ctm) {
return {
x: (e.clientX - ctm.e) / ctm.a,
y: (e.clientY - ctm.f) / ctm.d,
};
}
const rect = svg.getBoundingClientRect();
return {
x: ((e.clientX - rect.left) / rect.width) * CANVAS_W,
y: ((e.clientY - rect.top) / rect.height) * CANVAS_H,
};
}, []);
const syncCircuit = useCallback((g: PlacedGate[], w: Wire[]) => {
onCircuitChange({ gates: g, wires: w });
}, [onCircuitChange]);
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
if (disabled) return;
const pos = getSvgPoint(e);
if (placingGate) {
const newGate: PlacedGate = {
id: `gate-${nextGateId++}`,
type: placingGate,
x: pos.x - GATE_W / 2,
y: pos.y - GATE_H / 2,
};
const newGates = [...gates, newGate];
setGates(newGates);
syncCircuit(newGates, wires);
setPlacingGate(null);
return;
}
// Deselect
setSelectedGate(null);
setWiringFrom(null);
}, [disabled, placingGate, gates, wires, getSvgPoint, syncCircuit]);
// Determine if a port is a "source" (signal producer) or "sink" (signal consumer)
const isSourcePort = useCallback((portId: string) => {
return portId.includes(':out') || portId.startsWith('input-');
}, []);
// Start dragging a wire from any port
const handlePortDragStart = useCallback((portId: string, pos: { x: number; y: number }) => {
if (disabled) return;
setWiringFrom({ id: portId, x: pos.x, y: pos.y });
}, [disabled]);
// Complete wire when releasing on a port
const handlePortDragEnd = useCallback((portId: string, _pos: { x: number; y: number }) => {
if (disabled || !wiringFrom) return;
if (portId === wiringFrom.id) { setWiringFrom(null); return; } // same port
let from = wiringFrom.id;
let to = portId;
// Auto-detect direction: one must be source, other must be sink
const fromIsSource = isSourcePort(from);
const toIsSource = isSourcePort(to);
// If both are same type, can't connect
if (fromIsSource === toIsSource) { setWiringFrom(null); return; }
// Normalize: from = source (output/circuit-input), to = sink (input/circuit-output)
if (!fromIsSource && toIsSource) {
[from, to] = [to, from];
}
// Don't allow duplicate wires to same sink
const existing = wires.find((w) => w.to === to);
if (!existing) {
const newWire: Wire = { id: `wire-${Date.now()}`, from, to };
const newWires = [...wires, newWire];
setWires(newWires);
syncCircuit(gates, newWires);
}
setWiringFrom(null);
}, [disabled, wiringFrom, wires, gates, syncCircuit, isSourcePort]);
const handleGateMouseDown = useCallback((id: string, e: React.MouseEvent) => {
if (disabled) return;
setSelectedGate(id);
const gate = gates.find((g) => g.id === id);
if (!gate) return;
const pos = getSvgPoint(e);
setDragging({ id, offsetX: pos.x - gate.x, offsetY: pos.y - gate.y });
}, [disabled, gates, getSvgPoint]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
const pos = getSvgPoint(e);
setMousePos(pos);
if (dragging) {
const newGates = gates.map((g) =>
g.id === dragging.id
? { ...g, x: pos.x - dragging.offsetX, y: pos.y - dragging.offsetY }
: g
);
setGates(newGates);
syncCircuit(newGates, wires);
}
}, [dragging, gates, wires, getSvgPoint, syncCircuit]);
const handleMouseUp = useCallback(() => {
setDragging(null);
// Cancel wiring if released on empty space
if (wiringFrom) setWiringFrom(null);
}, [wiringFrom]);
const deleteGate = useCallback((id: string) => {
if (disabled) return;
const newGates = gates.filter((g) => g.id !== id);
const newWires = wires.filter((w) => !w.from.startsWith(id) && !w.to.startsWith(id));
setGates(newGates);
setWires(newWires);
syncCircuit(newGates, newWires);
if (selectedGate === id) setSelectedGate(null);
}, [disabled, gates, wires, selectedGate, syncCircuit]);
const clearAll = useCallback(() => {
setGates([]);
setWires([]);
setSelectedGate(null);
setWiringFrom(null);
syncCircuit([], []);
}, [syncCircuit]);
// Render wire bezier
function renderWire(from: { x: number; y: number }, to: { x: number; y: number }, color: string, key: string, isPreview = false) {
const dx = Math.max(Math.abs(to.x - from.x) * 0.5, 30);
const d = `M${from.x},${from.y} C${from.x + dx},${from.y} ${to.x - dx},${to.y} ${to.x},${to.y}`;
return <path key={key} d={d} fill="none" stroke={color} strokeWidth="2" strokeDasharray={isPreview ? '6 3' : undefined} pointerEvents="none" />;
}
// Shared gate palette
const paletteContent = (
<>
{content.availableGates.map((type) => (
<button key={type}
onClick={() => !disabled && setPlacingGate(placingGate === type ? null : type)}
className={`px-3 py-1.5 text-xs font-mono font-bold rounded border transition-colors ${
placingGate === type ? 'bg-primary/20 border-primary text-primary' : 'bg-muted border-border text-foreground hover:border-primary/50'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>{type}</button>
))}
<div className="border-t border-border my-1" />
<Button variant="ghost" size="sm" onClick={clearAll} disabled={disabled}
className="text-xs text-destructive hover:text-destructive">
<Trash2 className="w-3 h-3 mr-1" /> Limpiar
</Button>
</>
);
// Shared SVG canvas
const svgCanvas = (fs: boolean) => (
<svg ref={svgRef} viewBox={`0 0 ${CANVAS_W} ${CANVAS_H}`}
className={fs ? 'w-full h-full' : 'w-full'}
preserveAspectRatio={fs ? 'xMidYMid meet' : undefined}
style={{ minHeight: fs ? undefined : 300, cursor: placingGate ? 'crosshair' : wiringFrom ? 'crosshair' : 'default', userSelect: 'none' }}
onClick={handleCanvasClick} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp}>
<defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<circle cx="10" cy="10" r="0.5" fill="#333" />
</pattern>
</defs>
<rect width={CANVAS_W} height={CANVAS_H} fill="url(#grid)" />
{inputPorts.map((port, i) => (
<g key={port.id}>
<circle cx={port.x} cy={port.y} r={10} fill="#1e293b" stroke="#3b82f6" strokeWidth="2" style={{ cursor: 'crosshair' }}
onMouseDown={(e) => { e.stopPropagation(); handlePortDragStart(port.id, { x: port.x, y: port.y }); }}
onMouseUp={(e) => { e.stopPropagation(); handlePortDragEnd(port.id, { x: port.x, y: port.y }); }} />
<text x={port.x} y={port.y - 16} textAnchor="middle" fill="#3b82f6" fontSize="12" fontFamily="monospace" pointerEvents="none">{content.inputLabels[i]}</text>
</g>
))}
{outputPorts.map((port, i) => (
<g key={port.id}>
<circle cx={port.x} cy={port.y} r={10} fill="#1e293b" stroke="#f59e0b" strokeWidth="2" style={{ cursor: 'crosshair' }}
onMouseDown={(e) => { e.stopPropagation(); handlePortDragStart(port.id, { x: port.x, y: port.y }); }}
onMouseUp={(e) => { e.stopPropagation(); handlePortDragEnd(port.id, { x: port.x, y: port.y }); }} />
<text x={port.x} y={port.y - 16} textAnchor="middle" fill="#f59e0b" fontSize="12" fontFamily="monospace" pointerEvents="none">{content.outputLabels[i]}</text>
</g>
))}
{wires.map((wire) => { const from = allPortPositions.get(wire.from); const to = allPortPositions.get(wire.to); if (!from || !to) return null; return renderWire(from, to, '#64748b', wire.id); })}
{wiringFrom && mousePos && renderWire(wiringFrom, mousePos, '#6366f1', 'wiring-preview', true)}
{placingGate && mousePos && (
<rect x={mousePos.x - GATE_W / 2} y={mousePos.y - GATE_H / 2} width={GATE_W} height={GATE_H}
fill="none" stroke="#6366f1" strokeWidth="2" strokeDasharray="4" rx="4" pointerEvents="none" />
)}
{gates.map((gate) => (
<GateComponent key={gate.id} id={gate.id} type={gate.type} x={gate.x} y={gate.y}
selected={selectedGate === gate.id} onPortDragStart={handlePortDragStart} onPortDragEnd={handlePortDragEnd}
onGateMouseDown={handleGateMouseDown} onGateDelete={deleteGate} />
))}
</svg>
);
// Shared truth table
const truthTable = (
<div className="border border-border rounded-lg overflow-hidden shrink-0">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-muted/50">
{content.inputLabels.map((l) => <th key={l} className="px-3 py-1.5 text-center text-blue-400">{l}</th>)}
<th className="px-1 text-muted-foreground"></th>
{content.outputLabels.map((l) => <th key={l} className="px-3 py-1.5 text-center text-amber-400">{l}</th>)}
<th className="px-3 py-1.5 text-center text-muted-foreground">Tu resultado</th>
<th className="w-8" />
</tr>
</thead>
<tbody>
{content.truthTable.map((row, i) => {
const result = truthTableResults[i];
return (
<tr key={i} className={`border-t border-border ${result?.pass ? 'bg-green-500/5' : ''}`}>
{row.inputs.map((v, j) => <td key={j} className="px-3 py-1.5 text-center">{v ? '1' : '0'}</td>)}
<td className="px-1 text-muted-foreground text-center"></td>
{row.outputs.map((v, j) => <td key={j} className="px-3 py-1.5 text-center font-bold">{v ? '1' : '0'}</td>)}
<td className="px-3 py-1.5 text-center">{result?.actual ? result.actual.map((v) => v ? '1' : '0').join(', ') : <span className="text-muted-foreground"></span>}</td>
<td className="px-2 text-center">{result?.actual && (result.pass ? '✓' : '✗')}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
// Fullscreen button
const fsButton = (fs: boolean) => (
<Button variant="ghost" size="sm" onClick={() => setFullscreen(!fs)} title={fs ? 'Salir (Esc)' : 'Pantalla completa'}>
{fs ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
</Button>
);
// === FULLSCREEN PORTAL ===
const fullscreenOverlay = fullscreen && createPortal(
<div className="fixed inset-0 z-[500] bg-[#050510] flex animate-fade-in">
<div className="w-28 shrink-0 flex flex-col gap-1.5 p-3 bg-card/30 border-r border-border overflow-y-auto">
<p className="text-xs text-muted-foreground font-medium mb-1">Puertas</p>
{paletteContent}
</div>
<div className="flex-1 flex flex-col min-h-0">
<div className="px-3 py-2 border-b border-border bg-card/30 flex items-center justify-end">
{fsButton(true)}
</div>
<div className="flex-1 min-h-0 bg-[#0a0a0a]">{svgCanvas(true)}</div>
<div className="px-3 py-2 border-t border-border bg-card/30">{truthTable}</div>
</div>
</div>,
document.body
);
// === INLINE ===
return (
<div className="space-y-4">
<div className="flex gap-3">
<div className="flex flex-col gap-1.5 shrink-0">
<div className="flex items-center gap-1 mb-1">
<p className="text-xs text-muted-foreground font-medium flex-1">Puertas</p>
{fsButton(false)}
</div>
{paletteContent}
</div>
<div className="flex-1 border border-border rounded-lg overflow-hidden bg-[#0a0a0a]">
{svgCanvas(false)}
</div>
</div>
{truthTable}
<div className="text-xs text-muted-foreground space-y-1">
<p>Arrastra desde una salida/entrada a otra para conectar. Clic derecho para eliminar.</p>
</div>
{fullscreenOverlay}
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { GateType, gateInputCount } from '@/types/circuit';
export const GATE_W = 80;
export const GATE_H = 60;
export const PORT_R = 6;
interface GateComponentProps {
type: GateType;
x: number;
y: number;
id: string;
selected?: boolean;
signalOut?: boolean;
onPortDragStart?: (portId: string, pos: { x: number; y: number }) => void;
onPortDragEnd?: (portId: string, pos: { x: number; y: number }) => void;
onGateMouseDown?: (id: string, e: React.MouseEvent) => void;
onGateDelete?: (id: string) => void;
}
// Gate body shapes as SVG paths
function gateBody(type: GateType) {
switch (type) {
case 'AND':
return <path d="M0,0 L40,0 Q80,0 80,30 Q80,60 40,60 L0,60 Z" fill="#1e293b" stroke="#64748b" strokeWidth="2" />;
case 'OR':
return <path d="M0,0 Q20,30 0,60 Q40,60 80,30 Q40,0 0,0 Z" fill="#1e293b" stroke="#64748b" strokeWidth="2" />;
case 'NOT':
return (
<g>
<path d="M0,0 L60,30 L0,60 Z" fill="#1e293b" stroke="#64748b" strokeWidth="2" />
<circle cx="66" cy="30" r="6" fill="#1e293b" stroke="#64748b" strokeWidth="2" />
</g>
);
case 'NAND':
return (
<g>
<path d="M0,0 L35,0 Q72,0 72,30 Q72,60 35,60 L0,60 Z" fill="#1e293b" stroke="#64748b" strokeWidth="2" />
<circle cx="78" cy="30" r="6" fill="#1e293b" stroke="#64748b" strokeWidth="2" />
</g>
);
case 'NOR':
return (
<g>
<path d="M0,0 Q15,30 0,60 Q35,60 68,30 Q35,0 0,0 Z" fill="#1e293b" stroke="#64748b" strokeWidth="2" />
<circle cx="74" cy="30" r="6" fill="#1e293b" stroke="#64748b" strokeWidth="2" />
</g>
);
case 'XOR':
return (
<g>
<path d="M8,0 Q28,30 8,60 Q48,60 80,30 Q48,0 8,0 Z" fill="#1e293b" stroke="#64748b" strokeWidth="2" />
<path d="M0,0 Q20,30 0,60" fill="none" stroke="#64748b" strokeWidth="2" />
</g>
);
case 'XNOR':
return (
<g>
<path d="M8,0 Q28,30 8,60 Q45,60 68,30 Q45,0 8,0 Z" fill="#1e293b" stroke="#64748b" strokeWidth="2" />
<path d="M0,0 Q20,30 0,60" fill="none" stroke="#64748b" strokeWidth="2" />
<circle cx="74" cy="30" r="6" fill="#1e293b" stroke="#64748b" strokeWidth="2" />
</g>
);
}
}
export function getInputPortPositions(type: GateType): { x: number; y: number }[] {
const count = gateInputCount(type);
if (count === 1) return [{ x: 0, y: GATE_H / 2 }];
return [
{ x: 0, y: GATE_H * 0.25 },
{ x: 0, y: GATE_H * 0.75 },
];
}
export function getOutputPortPosition(): { x: number; y: number } {
return { x: GATE_W, y: GATE_H / 2 };
}
export function GateComponent({
type, x, y, id, selected, signalOut,
onPortDragStart, onPortDragEnd, onGateMouseDown, onGateDelete,
}: GateComponentProps) {
const inputs = getInputPortPositions(type);
const output = getOutputPortPosition();
return (
<g transform={`translate(${x},${y})`}>
{/* Hit area for drag */}
<rect
x={-4} y={-4}
width={GATE_W + 8} height={GATE_H + 8}
fill="transparent"
onMouseDown={(e) => { e.stopPropagation(); onGateMouseDown?.(id, e); }}
onContextMenu={(e) => { e.preventDefault(); onGateDelete?.(id); }}
style={{ cursor: 'move' }}
/>
{/* Body */}
{gateBody(type)}
{/* Label */}
<text x={GATE_W / 2} y={GATE_H / 2} textAnchor="middle" dominantBaseline="central"
fill="#94a3b8" fontSize="10" fontFamily="monospace" pointerEvents="none"
>
{type}
</text>
{/* Selection highlight */}
{selected && (
<rect x={-3} y={-3} width={GATE_W + 6} height={GATE_H + 6}
fill="none" stroke="#6366f1" strokeWidth="2" strokeDasharray="4" rx="4" />
)}
{/* Input ports */}
{inputs.map((pos, i) => (
<circle
key={`in-${i}`}
cx={pos.x} cy={pos.y} r={PORT_R + 2}
fill="#0f172a" stroke="#64748b" strokeWidth="2"
style={{ cursor: 'crosshair' }}
onMouseDown={(e) => { e.stopPropagation(); onPortDragStart?.(`${id}:in-${i}`, { x: x + pos.x, y: y + pos.y }); }}
onMouseUp={(e) => { e.stopPropagation(); onPortDragEnd?.(`${id}:in-${i}`, { x: x + pos.x, y: y + pos.y }); }}
/>
))}
{/* Output port */}
<circle
cx={output.x} cy={output.y} r={PORT_R + 2}
fill={signalOut === true ? '#22c55e' : signalOut === false ? '#374151' : '#0f172a'}
stroke={signalOut !== undefined ? (signalOut ? '#22c55e' : '#6b7280') : '#64748b'}
strokeWidth="2"
style={{ cursor: 'crosshair' }}
onMouseDown={(e) => { e.stopPropagation(); onPortDragStart?.(`${id}:out`, { x: x + output.x, y: y + output.y }); }}
onMouseUp={(e) => { e.stopPropagation(); onPortDragEnd?.(`${id}:out`, { x: x + output.x, y: y + output.y }); }}
/>
</g>
);
}

View File

@@ -0,0 +1,85 @@
import { CircuitState, evaluateGate, gateInputCount } from '@/types/circuit';
/**
* Simulate a circuit with given input values.
* Returns output values or an error string.
*/
export function simulateCircuit(
circuit: CircuitState,
inputValues: boolean[]
): boolean[] | string {
// Map: portId -> boolean value
const signals = new Map<string, boolean>();
// Seed circuit inputs
inputValues.forEach((val, i) => {
signals.set(`input-${i}`, val);
});
// Build a map of wires: toPort -> fromPort
const wireSource = new Map<string, string>();
for (const wire of circuit.wires) {
wireSource.set(wire.to, wire.from);
}
// Topological evaluation: iterate until stable or stuck
const maxIterations = circuit.gates.length + 1;
let resolved = 0;
const gateResolved = new Set<string>();
for (let iter = 0; iter < maxIterations; iter++) {
let progress = false;
for (const gate of circuit.gates) {
if (gateResolved.has(gate.id)) continue;
const numInputs = gateInputCount(gate.type);
const inputVals: boolean[] = [];
let allResolved = true;
for (let i = 0; i < numInputs; i++) {
const portId = `${gate.id}:in-${i}`;
const source = wireSource.get(portId);
if (!source || !signals.has(source)) {
allResolved = false;
break;
}
inputVals.push(signals.get(source)!);
}
if (allResolved) {
const output = evaluateGate(gate.type, inputVals);
signals.set(`${gate.id}:out`, output);
gateResolved.add(gate.id);
resolved++;
progress = true;
}
}
if (!progress) break;
}
if (gateResolved.size < circuit.gates.length) {
return 'Hay puertas sin conectar o con conexiones incompletas';
}
// Read circuit outputs
const outputs: boolean[] = [];
let i = 0;
while (true) {
const outPort = `output-${i}`;
const source = wireSource.get(outPort);
if (source === undefined) break;
if (!signals.has(source)) {
return `La salida ${i} no está conectada`;
}
outputs.push(signals.get(source)!);
i++;
}
if (outputs.length === 0) {
return 'No hay salidas conectadas';
}
return outputs;
}

View File

@@ -0,0 +1,276 @@
import { ElectronicComponent, ElectronicComponentType, getTerminals } from '@/types/electronics';
export const COMP_W = 80;
export const COMP_H = 60;
const TERM_R = 7;
interface ComponentSVGProps {
component: ElectronicComponent;
selected?: boolean;
voltage?: Map<string, number>;
meterReading?: { value: number; unit: string };
onMouseDown?: (id: string, e: React.MouseEvent) => void;
onDelete?: (id: string) => void;
onTerminalDragStart?: (termId: string, pos: { x: number; y: number }) => void;
onTerminalDragEnd?: (termId: string, pos: { x: number; y: number }) => void;
}
/** Get terminal positions in world coords for a placed component */
export function getTerminalPositions(
type: ElectronicComponentType,
x: number,
y: number,
rotation: number
): Record<string, { x: number; y: number }> {
const localPositions = getLocalTerminalPositions(type);
const result: Record<string, { x: number; y: number }> = {};
const cx = x + COMP_W / 2;
const cy = y + COMP_H / 2;
for (const [name, pos] of Object.entries(localPositions)) {
const rotated = rotatePoint(pos.x - COMP_W / 2, pos.y - COMP_H / 2, rotation);
result[name] = { x: cx + rotated.x, y: cy + rotated.y };
}
return result;
}
function getLocalTerminalPositions(type: ElectronicComponentType): Record<string, { x: number; y: number }> {
switch (type) {
case 'voltage-source':
return { pos: { x: COMP_W / 2, y: 0 }, neg: { x: COMP_W / 2, y: COMP_H } };
case 'resistor':
return { a: { x: 0, y: COMP_H / 2 }, b: { x: COMP_W, y: COMP_H / 2 } };
case 'capacitor':
return { a: { x: 0, y: COMP_H / 2 }, b: { x: COMP_W, y: COMP_H / 2 } };
case 'led':
return { anode: { x: 0, y: COMP_H / 2 }, cathode: { x: COMP_W, y: COMP_H / 2 } };
case 'switch':
return { a: { x: 0, y: COMP_H / 2 }, b: { x: COMP_W, y: COMP_H / 2 } };
case 'ground':
return { gnd: { x: COMP_W / 2, y: 0 } };
case 'nmos':
case 'pmos':
return {
gate: { x: 0, y: COMP_H / 2 },
drain: { x: COMP_W, y: COMP_H * 0.2 },
source: { x: COMP_W, y: COMP_H * 0.8 },
};
case 'voltmeter':
return { pos: { x: 0, y: COMP_H / 2 }, neg: { x: COMP_W, y: COMP_H / 2 } };
case 'ammeter':
return { a: { x: 0, y: COMP_H / 2 }, b: { x: COMP_W, y: COMP_H / 2 } };
}
}
function rotatePoint(x: number, y: number, angle: number): { x: number; y: number } {
const rad = (angle * Math.PI) / 180;
return {
x: x * Math.cos(rad) - y * Math.sin(rad),
y: x * Math.sin(rad) + y * Math.cos(rad),
};
}
function componentBody(type: ElectronicComponentType, value?: number) {
switch (type) {
case 'voltage-source':
return (
<g>
<circle cx={COMP_W / 2} cy={COMP_H / 2} r={18} fill="none" stroke="#ef4444" strokeWidth="2" />
<text x={COMP_W / 2} y={COMP_H / 2 - 4} textAnchor="middle" fill="#ef4444" fontSize="14" fontWeight="bold">+</text>
<text x={COMP_W / 2} y={COMP_H / 2 + 12} textAnchor="middle" fill="#ef4444" fontSize="10"></text>
<text x={COMP_W / 2} y={COMP_H + 14} textAnchor="middle" fill="#94a3b8" fontSize="9">{value ?? 5}V</text>
</g>
);
case 'resistor':
return (
<g>
<path d="M0,30 L15,30 L20,18 L30,42 L40,18 L50,42 L60,18 L65,30 L80,30" fill="none" stroke="#a78bfa" strokeWidth="2" />
<text x={COMP_W / 2} y={COMP_H + 12} textAnchor="middle" fill="#94a3b8" fontSize="9">{formatResistance(value ?? 1000)}</text>
</g>
);
case 'capacitor':
return (
<g>
{/* Two parallel plates */}
<line x1="0" y1={COMP_H / 2} x2="35" y2={COMP_H / 2} stroke="#06b6d4" strokeWidth="2" />
<line x1="35" y1="12" x2="35" y2="48" stroke="#06b6d4" strokeWidth="3" />
<line x1="45" y1="12" x2="45" y2="48" stroke="#06b6d4" strokeWidth="3" />
<line x1="45" y1={COMP_H / 2} x2={COMP_W} y2={COMP_H / 2} stroke="#06b6d4" strokeWidth="2" />
<text x={COMP_W / 2} y={COMP_H + 12} textAnchor="middle" fill="#94a3b8" fontSize="9">{formatCapacitance(value ?? 100)}</text>
</g>
);
case 'led':
return (
<g>
<polygon points="25,18 25,42 55,30" fill="none" stroke="#22c55e" strokeWidth="2" />
<line x1="55" y1="18" x2="55" y2="42" stroke="#22c55e" strokeWidth="2" />
<line x1="0" y1="30" x2="25" y2="30" stroke="#64748b" strokeWidth="2" />
<line x1="55" y1="30" x2="80" y2="30" stroke="#64748b" strokeWidth="2" />
{/* Light rays */}
<line x1="45" y1="12" x2="50" y2="6" stroke="#22c55e" strokeWidth="1" />
<line x1="50" y1="14" x2="55" y2="8" stroke="#22c55e" strokeWidth="1" />
</g>
);
case 'switch':
return (
<g>
<line x1="0" y1="30" x2="25" y2="30" stroke="#64748b" strokeWidth="2" />
<line x1="55" y1="30" x2="80" y2="30" stroke="#64748b" strokeWidth="2" />
<circle cx="25" cy="30" r="3" fill="#f59e0b" />
<circle cx="55" cy="30" r="3" fill="#f59e0b" />
<line x1="25" y1="30" x2="52" y2="18" stroke="#f59e0b" strokeWidth="2" />
<text x={COMP_W / 2} y={COMP_H + 12} textAnchor="middle" fill="#94a3b8" fontSize="9">SW</text>
</g>
);
case 'ground':
return (
<g>
<line x1="40" y1="0" x2="40" y2="25" stroke="#64748b" strokeWidth="2" />
<line x1="20" y1="25" x2="60" y2="25" stroke="#64748b" strokeWidth="2" />
<line x1="27" y1="32" x2="53" y2="32" stroke="#64748b" strokeWidth="2" />
<line x1="33" y1="39" x2="47" y2="39" stroke="#64748b" strokeWidth="2" />
</g>
);
case 'nmos':
return (
<g>
{/* Gate line */}
<line x1="0" y1="30" x2="25" y2="30" stroke="#64748b" strokeWidth="2" />
<line x1="25" y1="12" x2="25" y2="48" stroke="#64748b" strokeWidth="2" />
{/* Channel */}
<line x1="30" y1="12" x2="30" y2="48" stroke="#3b82f6" strokeWidth="3" />
{/* Drain */}
<line x1="30" y1="18" x2="60" y2="18" stroke="#3b82f6" strokeWidth="2" />
<line x1="60" y1="12" x2="60" y2="18" stroke="#3b82f6" strokeWidth="2" />
<line x1="60" y1="12" x2="80" y2="12" stroke="#64748b" strokeWidth="2" />
{/* Source */}
<line x1="30" y1="42" x2="60" y2="42" stroke="#3b82f6" strokeWidth="2" />
<line x1="60" y1="42" x2="60" y2="48" stroke="#3b82f6" strokeWidth="2" />
<line x1="60" y1="48" x2="80" y2="48" stroke="#64748b" strokeWidth="2" />
{/* Arrow on source */}
<polygon points="42,42 48,38 48,46" fill="#3b82f6" />
<text x={COMP_W / 2} y={COMP_H + 12} textAnchor="middle" fill="#94a3b8" fontSize="9">NMOS</text>
</g>
);
case 'pmos':
return (
<g>
<line x1="0" y1="30" x2="22" y2="30" stroke="#64748b" strokeWidth="2" />
<circle cx="24" cy="30" r="3" fill="none" stroke="#64748b" strokeWidth="1.5" />
<line x1="27" y1="12" x2="27" y2="48" stroke="#64748b" strokeWidth="2" />
<line x1="30" y1="12" x2="30" y2="48" stroke="#ef4444" strokeWidth="3" />
<line x1="30" y1="18" x2="60" y2="18" stroke="#ef4444" strokeWidth="2" />
<line x1="60" y1="12" x2="60" y2="18" stroke="#ef4444" strokeWidth="2" />
<line x1="60" y1="12" x2="80" y2="12" stroke="#64748b" strokeWidth="2" />
<line x1="30" y1="42" x2="60" y2="42" stroke="#ef4444" strokeWidth="2" />
<line x1="60" y1="42" x2="60" y2="48" stroke="#ef4444" strokeWidth="2" />
<line x1="60" y1="48" x2="80" y2="48" stroke="#64748b" strokeWidth="2" />
<polygon points="48,42 42,38 42,46" fill="#ef4444" />
<text x={COMP_W / 2} y={COMP_H + 12} textAnchor="middle" fill="#94a3b8" fontSize="9">PMOS</text>
</g>
);
case 'voltmeter':
return (
<g>
<circle cx={COMP_W / 2} cy={COMP_H / 2} r={20} fill="none" stroke="#3b82f6" strokeWidth="2" />
<text x={COMP_W / 2} y={COMP_H / 2 + 1} textAnchor="middle" dominantBaseline="central" fill="#3b82f6" fontSize="16" fontWeight="bold">V</text>
<line x1="0" y1={COMP_H / 2} x2={COMP_W / 2 - 20} y2={COMP_H / 2} stroke="#64748b" strokeWidth="2" />
<line x1={COMP_W / 2 + 20} y1={COMP_H / 2} x2={COMP_W} y2={COMP_H / 2} stroke="#64748b" strokeWidth="2" />
{/* + and - labels */}
<text x={8} y={COMP_H / 2 - 10} fill="#ef4444" fontSize="10" fontWeight="bold">+</text>
<text x={COMP_W - 12} y={COMP_H / 2 - 10} fill="#64748b" fontSize="10" fontWeight="bold"></text>
</g>
);
case 'ammeter':
return (
<g>
<circle cx={COMP_W / 2} cy={COMP_H / 2} r={20} fill="none" stroke="#f59e0b" strokeWidth="2" />
<text x={COMP_W / 2} y={COMP_H / 2 + 1} textAnchor="middle" dominantBaseline="central" fill="#f59e0b" fontSize="16" fontWeight="bold">A</text>
<line x1="0" y1={COMP_H / 2} x2={COMP_W / 2 - 20} y2={COMP_H / 2} stroke="#64748b" strokeWidth="2" />
<line x1={COMP_W / 2 + 20} y1={COMP_H / 2} x2={COMP_W} y2={COMP_H / 2} stroke="#64748b" strokeWidth="2" />
</g>
);
}
}
function formatResistance(ohms: number): string {
if (ohms >= 1e6) return `${ohms / 1e6}`;
if (ohms >= 1e3) return `${ohms / 1e3}`;
return `${ohms}Ω`;
}
function formatCapacitance(uf: number): string {
if (uf >= 1000) return `${uf / 1000}mF`;
if (uf >= 1) return `${uf}µF`;
if (uf >= 0.001) return `${uf * 1000}nF`;
return `${uf * 1e6}pF`;
}
export function ComponentSVG({
component, selected, voltage, meterReading, onMouseDown, onDelete, onTerminalDragStart, onTerminalDragEnd,
}: ComponentSVGProps) {
const { id, type, x, y, rotation } = component;
const terminals = getTerminalPositions(type, x, y, rotation);
const isMeter = type === 'voltmeter' || type === 'ammeter';
return (
<g>
{/* Meter reading display — always centered above the component regardless of rotation */}
{isMeter && meterReading && (
<g transform={`translate(${x + COMP_W / 2},${y + COMP_H / 2})`}>
<rect
x={-32} y={-COMP_H / 2 - 26}
width={64} height={20} rx={4}
fill="#0f172a" stroke={type === 'voltmeter' ? '#3b82f6' : '#f59e0b'} strokeWidth="1"
/>
<text
x={0} y={-COMP_H / 2 - 14}
textAnchor="middle" dominantBaseline="central"
fill={type === 'voltmeter' ? '#3b82f6' : '#f59e0b'}
fontSize="11" fontFamily="monospace" fontWeight="bold" pointerEvents="none"
>
{meterReading.value.toFixed(meterReading.unit === 'mA' ? 1 : 2)} {meterReading.unit}
</text>
</g>
)}
{/* Rotated body */}
<g transform={`translate(${x + COMP_W / 2},${y + COMP_H / 2}) rotate(${rotation}) translate(${-COMP_W / 2},${-COMP_H / 2})`}>
{/* Hit area */}
<rect x={-4} y={-4} width={COMP_W + 8} height={COMP_H + 8} fill="transparent"
onMouseDown={(e) => { e.stopPropagation(); onMouseDown?.(id, e); }}
onContextMenu={(e) => { e.preventDefault(); onDelete?.(id); }}
style={{ cursor: 'move' }}
/>
{componentBody(type, component.value)}
{selected && (
<rect x={-3} y={-3} width={COMP_W + 6} height={COMP_H + 6}
fill="none" stroke="#6366f1" strokeWidth="2" strokeDasharray="4" rx="4" />
)}
</g>
{/* Terminals (in world coords, not rotated) */}
{Object.entries(terminals).map(([name, pos]) => {
const termId = `${id}:${name}`;
const v = voltage?.get(termId);
const color = v !== undefined ? (v > 3 ? '#ef4444' : v > 0.5 ? '#f59e0b' : '#555') : '#64748b';
return (
<g key={name}>
<circle
cx={pos.x} cy={pos.y} r={TERM_R}
fill="#0f172a" stroke={color} strokeWidth="2"
style={{ cursor: 'crosshair' }}
onMouseDown={(e) => { e.stopPropagation(); onTerminalDragStart?.(termId, pos); }}
onMouseUp={(e) => { e.stopPropagation(); onTerminalDragEnd?.(termId, pos); }}
/>
{v !== undefined && (
<text x={pos.x} y={pos.y - 12} textAnchor="middle" fill={color} fontSize="8" pointerEvents="none">
{v}V
</text>
)}
</g>
);
})}
</g>
);
}

View File

@@ -0,0 +1,453 @@
'use client';
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { createPortal } from 'react-dom';
import {
ElectronicsContent,
ElectronicCircuitState,
ElectronicComponent,
ElectronicWire,
ElectronicComponentType,
getTerminals,
componentLabel,
} from '@/types/electronics';
import { simulateElectronics } from './simulateElectronics';
import { ComponentSVG, COMP_W, COMP_H, getTerminalPositions } from './ComponentSVG';
import { Trash2, RotateCw, Play, Pause, Maximize2, Minimize2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ElectronicsLabProps {
content: ElectronicsContent;
onCircuitChange: (state: ElectronicCircuitState) => void;
disabled?: boolean;
}
const CANVAS_W = 700;
const CANVAS_H = 500;
interface TermPos { id: string; x: number; y: number }
let nextCompId = 1;
export function ElectronicsLab({ content, onCircuitChange, disabled }: ElectronicsLabProps) {
const [components, setComponents] = useState<ElectronicComponent[]>(content.preplacedComponents ?? []);
const [wires, setWires] = useState<ElectronicWire[]>(content.preplacedWires ?? []);
const [placing, setPlacing] = useState<ElectronicComponentType | null>(null);
const [wiringFrom, setWiringFrom] = useState<TermPos | null>(null);
const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null);
const [dragging, setDragging] = useState<{ id: string; ox: number; oy: number } | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const didDrag = useRef(false);
const svgRef = useRef<SVGSVGElement>(null);
// Escape exits fullscreen
useEffect(() => {
if (!fullscreen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setFullscreen(false); }
};
window.addEventListener('keydown', handleKey, true);
return () => window.removeEventListener('keydown', handleKey, true);
}, [fullscreen]);
const circuit: ElectronicCircuitState = useMemo(() => ({ components, wires }), [components, wires]);
const simResult = useMemo(() => {
if (!running) return null;
return simulateElectronics(circuit);
}, [circuit, running]);
const sync = useCallback((c: ElectronicComponent[], w: ElectronicWire[]) => {
onCircuitChange({ components: c, wires: w });
}, [onCircuitChange]);
const getSvgPoint = useCallback((e: React.MouseEvent) => {
const svg = svgRef.current;
if (!svg) return { x: 0, y: 0 };
const ctm = svg.getScreenCTM();
if (ctm) {
return {
x: (e.clientX - ctm.e) / ctm.a,
y: (e.clientY - ctm.f) / ctm.d,
};
}
// Fallback
const rect = svg.getBoundingClientRect();
return {
x: ((e.clientX - rect.left) / rect.width) * CANVAS_W,
y: ((e.clientY - rect.top) / rect.height) * CANVAS_H,
};
}, []);
// Get all terminal world positions
const allTerminals = useMemo(() => {
const map = new Map<string, { x: number; y: number }>();
for (const comp of components) {
const terminals = getTerminalPositions(comp.type, comp.x, comp.y, comp.rotation);
for (const [name, pos] of Object.entries(terminals)) {
map.set(`${comp.id}:${name}`, pos);
}
}
return map;
}, [components]);
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
if (disabled) return;
const pos = getSvgPoint(e);
if (placing) {
const newComp: ElectronicComponent = {
id: `comp-${nextCompId++}`,
type: placing,
x: pos.x - COMP_W / 2,
y: pos.y - COMP_H / 2,
rotation: 0,
value: placing === 'voltage-source' ? 5 : placing === 'resistor' ? 1000 : placing === 'capacitor' ? 100 : undefined,
};
const nc = [...components, newComp];
setComponents(nc);
sync(nc, wires);
setPlacing(null);
return;
}
// Don't deselect if we just finished dragging a component
if (!didDrag.current) {
setSelected(null);
}
didDrag.current = false;
setWiringFrom(null);
}, [disabled, placing, components, wires, getSvgPoint, sync]);
const handleTerminalDragStart = useCallback((termId: string, pos: { x: number; y: number }) => {
if (disabled) return;
setWiringFrom({ id: termId, ...pos });
}, [disabled]);
const handleTerminalDragEnd = useCallback((termId: string) => {
if (disabled || !wiringFrom) return;
if (termId === wiringFrom.id) { setWiringFrom(null); return; }
const existing = wires.find((w) =>
(w.from === wiringFrom.id && w.to === termId) ||
(w.from === termId && w.to === wiringFrom.id)
);
if (!existing) {
const newWire: ElectronicWire = { id: `ew-${Date.now()}`, from: wiringFrom.id, to: termId };
const nw = [...wires, newWire];
setWires(nw);
sync(components, nw);
}
setWiringFrom(null);
}, [disabled, wiringFrom, wires, components, sync]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
const pos = getSvgPoint(e);
setMousePos(pos);
if (dragging) {
didDrag.current = true;
const nc = components.map((c) =>
c.id === dragging.id ? { ...c, x: pos.x - dragging.ox, y: pos.y - dragging.oy } : c
);
setComponents(nc);
sync(nc, wires);
}
}, [dragging, components, wires, getSvgPoint, sync]);
const handleMouseUp = useCallback(() => {
setDragging(null);
if (wiringFrom) setWiringFrom(null);
}, [wiringFrom]);
const handleCompMouseDown = useCallback((id: string, e: React.MouseEvent) => {
if (disabled) return;
// Mark as interacted so canvas click doesn't deselect
didDrag.current = true;
setSelected(id);
const comp = components.find((c) => c.id === id);
if (!comp) return;
const pos = getSvgPoint(e);
setDragging({ id, ox: pos.x - comp.x, oy: pos.y - comp.y });
}, [disabled, components, getSvgPoint]);
const deleteComp = useCallback((id: string) => {
if (disabled) return;
const nc = components.filter((c) => c.id !== id);
const nw = wires.filter((w) => !w.from.startsWith(id) && !w.to.startsWith(id));
setComponents(nc);
setWires(nw);
sync(nc, nw);
if (selected === id) setSelected(null);
}, [disabled, components, wires, selected, sync]);
const rotateComp = useCallback((id: string) => {
if (disabled) return;
const nc = components.map((c) =>
c.id === id ? { ...c, rotation: ((c.rotation + 90) % 360) as 0 | 90 | 180 | 270 } : c
);
setComponents(nc);
sync(nc, wires);
}, [disabled, components, wires, sync]);
const changeValue = useCallback((id: string, value: number) => {
const nc = components.map((c) => c.id === id ? { ...c, value } : c);
setComponents(nc);
sync(nc, wires);
}, [components, wires, sync]);
const clearAll = useCallback(() => {
const preplaced = content.preplacedComponents ?? [];
const preWires = content.preplacedWires ?? [];
setComponents(preplaced);
setWires(preWires);
setSelected(null);
setWiringFrom(null);
sync(preplaced, preWires);
}, [content, sync]);
function renderWire(from: { x: number; y: number }, to: { x: number; y: number }, color: string, key: string, dashed = false) {
const dx = Math.max(Math.abs(to.x - from.x) * 0.4, 20);
const d = `M${from.x},${from.y} C${from.x + dx},${from.y} ${to.x - dx},${to.y} ${to.x},${to.y}`;
return <path key={key} d={d} fill="none" stroke={color} strokeWidth="2" strokeDasharray={dashed ? '6 3' : undefined} pointerEvents="none" />;
}
// Color wire by voltage difference
function wireColor(wireObj: ElectronicWire): string {
if (!simResult?.success) return '#555';
const vFrom = simResult.nodeVoltages.get(wireObj.from) ?? 0;
const vTo = simResult.nodeVoltages.get(wireObj.to) ?? 0;
const avg = (vFrom + vTo) / 2;
if (avg > 3) return '#ef4444'; // high voltage = red
if (avg > 0.5) return '#f59e0b'; // medium = amber
return '#555'; // low/ground = gray
}
// Shared palette content
const paletteContent = (
<>
{content.availableComponents.map((type) => (
<button
key={type}
onClick={() => !disabled && setPlacing(placing === type ? null : type)}
className={`px-2 py-1.5 text-[11px] font-medium rounded border transition-colors text-left ${
placing === type
? 'bg-primary/20 border-primary text-primary'
: 'bg-muted border-border text-foreground hover:border-primary/50'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{componentLabel(type)}
</button>
))}
<div className="border-t border-border my-1" />
{selected && (
<div className="space-y-1">
<Button variant="ghost" size="sm" onClick={() => rotateComp(selected)} disabled={disabled} className="w-full text-xs justify-start">
<RotateCw className="w-3 h-3 mr-1" /> Rotar
</Button>
{components.find((c) => c.id === selected)?.type === 'resistor' && (
<div className="px-1">
<label className="text-[10px] text-muted-foreground">Ω</label>
<input type="number" className="w-full px-2 py-1 text-xs bg-muted border border-border rounded"
value={components.find((c) => c.id === selected)?.value ?? 1000}
onChange={(e) => changeValue(selected, parseFloat(e.target.value) || 1000)}
onKeyDown={(e) => e.stopPropagation()} />
</div>
)}
{components.find((c) => c.id === selected)?.type === 'voltage-source' && (
<div className="px-1">
<label className="text-[10px] text-muted-foreground">Voltios</label>
<input type="number" className="w-full px-2 py-1 text-xs bg-muted border border-border rounded"
value={components.find((c) => c.id === selected)?.value ?? 5}
onChange={(e) => changeValue(selected, parseFloat(e.target.value) || 5)}
onKeyDown={(e) => e.stopPropagation()} />
</div>
)}
{components.find((c) => c.id === selected)?.type === 'capacitor' && (
<div className="px-1">
<label className="text-[10px] text-muted-foreground">µF</label>
<input type="number" className="w-full px-2 py-1 text-xs bg-muted border border-border rounded"
value={components.find((c) => c.id === selected)?.value ?? 100}
onChange={(e) => changeValue(selected, parseFloat(e.target.value) || 100)}
onKeyDown={(e) => e.stopPropagation()} />
</div>
)}
<Button variant="ghost" size="sm" onClick={() => deleteComp(selected)} disabled={disabled}
className="w-full text-xs justify-start text-destructive hover:text-destructive">
<Trash2 className="w-3 h-3 mr-1" /> Eliminar
</Button>
</div>
)}
<div className="flex-1" />
<Button variant="ghost" size="sm" onClick={clearAll} disabled={disabled}
className="text-xs text-destructive hover:text-destructive">
<Trash2 className="w-3 h-3 mr-1" /> Limpiar
</Button>
</>
);
// Shared SVG canvas
const svgCanvas = (fs: boolean) => (
<svg
ref={svgRef}
viewBox={`0 0 ${CANVAS_W} ${CANVAS_H}`}
className={fs ? 'w-full h-full' : 'w-full'}
preserveAspectRatio={fs ? 'xMidYMid meet' : undefined}
style={{ minHeight: fs ? undefined : 350, cursor: placing ? 'crosshair' : wiringFrom ? 'crosshair' : 'default', userSelect: 'none' }}
onClick={handleCanvasClick}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<defs>
<pattern id="egrid" width="20" height="20" patternUnits="userSpaceOnUse">
<circle cx="10" cy="10" r="0.5" fill="#333" />
</pattern>
</defs>
<rect width={CANVAS_W} height={CANVAS_H} fill="url(#egrid)" />
{wires.map((wire) => {
const from = allTerminals.get(wire.from);
const to = allTerminals.get(wire.to);
if (!from || !to) return null;
return renderWire(from, to, wireColor(wire), wire.id);
})}
{wiringFrom && mousePos && renderWire(wiringFrom, mousePos, '#6366f1', 'wire-preview', true)}
{placing && mousePos && (
<rect x={mousePos.x - COMP_W / 2} y={mousePos.y - COMP_H / 2}
width={COMP_W} height={COMP_H}
fill="none" stroke="#6366f1" strokeWidth="2" strokeDasharray="4" rx="4" pointerEvents="none" />
)}
{components.map((comp) => (
<ComponentSVG key={comp.id} component={comp} selected={selected === comp.id}
voltage={simResult?.success ? simResult.nodeVoltages : undefined}
meterReading={simResult?.success ? simResult.meterReadings.get(comp.id) : undefined}
onMouseDown={handleCompMouseDown} onDelete={deleteComp}
onTerminalDragStart={handleTerminalDragStart} onTerminalDragEnd={handleTerminalDragEnd} />
))}
</svg>
);
// Shared probe table
const probeTable = content.probes.length > 0 && (
<div className="border border-border rounded-lg overflow-hidden shrink-0">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-muted/50">
<th className="px-3 py-1.5 text-left text-muted-foreground">Medición</th>
<th className="px-3 py-1.5 text-center text-muted-foreground">Esperado</th>
<th className="px-3 py-1.5 text-center text-muted-foreground">Tu resultado</th>
<th className="w-8" />
</tr>
</thead>
<tbody>
{content.probes.map((probe, i) => {
// Find meter readings of the matching type
const readings: number[] = [];
if (simResult?.success) {
for (const [, reading] of simResult.meterReadings) {
if ((probe.type === 'voltmeter' && reading.unit === 'V') ||
(probe.type === 'ammeter' && reading.unit === 'mA')) {
readings.push(reading.value);
}
}
}
const match = readings.find((r) => Math.abs(r - probe.expected) <= probe.tolerance);
const hasReading = readings.length > 0;
const pass = match !== undefined;
const instrumentName = probe.type === 'voltmeter' ? 'Voltímetro' : 'Amperímetro';
return (
<tr key={i} className={`border-t border-border ${pass ? 'bg-green-500/5' : ''}`}>
<td className="px-3 py-1.5">
<span className="text-muted-foreground text-[10px]">{instrumentName}:</span>{' '}
{probe.label}
</td>
<td className="px-3 py-1.5 text-center font-bold">{probe.expected}{probe.unit}</td>
<td className="px-3 py-1.5 text-center">
{hasReading
? readings.map((r) => r.toFixed(1) + probe.unit).join(', ')
: <span className="text-muted-foreground">Coloca un {instrumentName.toLowerCase()}</span>
}
</td>
<td className="px-2 text-center">{hasReading && (pass ? '✓' : '✗')}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
// Play/pause + fullscreen toolbar
const toolbar = (fs: boolean) => (
<div className="flex items-center gap-2 shrink-0">
<Button variant={running ? 'default' : 'outline'} size="sm"
onClick={() => setRunning((r) => !r)}
className={running ? 'bg-green-600 hover:bg-green-700 text-white' : ''}>
{running ? <Pause className="w-3.5 h-3.5 mr-1.5" /> : <Play className="w-3.5 h-3.5 mr-1.5" />}
{running ? 'Pausar' : 'Simular'}
</Button>
<span className={`text-xs flex-1 ${running ? 'text-green-400' : 'text-muted-foreground'}`}>
{running ? '● Simulación activa' : '○ Pausada'}
</span>
<Button variant="ghost" size="sm" onClick={() => setFullscreen(!fs)} title={fs ? 'Salir (Esc)' : 'Pantalla completa'}>
{fs ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
</Button>
</div>
);
// === FULLSCREEN PORTAL ===
const fullscreenOverlay = fullscreen && createPortal(
<div className="fixed inset-0 z-[500] bg-[#050510] flex animate-fade-in">
{/* Sidebar palette */}
<div className="w-32 shrink-0 flex flex-col gap-1.5 p-3 bg-card/30 border-r border-border overflow-y-auto">
<div className="flex items-center justify-between mb-1">
<p className="text-xs text-muted-foreground font-medium">Componentes</p>
</div>
{paletteContent}
</div>
{/* Main area */}
<div className="flex-1 flex flex-col min-h-0">
<div className="px-3 py-2 border-b border-border bg-card/30">
{toolbar(true)}
</div>
<div className="flex-1 min-h-0 bg-[#0a0a0a]">
{svgCanvas(true)}
</div>
<div className="px-3 py-2 border-t border-border bg-card/30">
{probeTable}
{simResult && !simResult.success && simResult.error && components.length > 0 && (
<p className="text-xs text-amber-500 mt-1">{simResult.error}</p>
)}
</div>
</div>
</div>,
document.body
);
// === INLINE (normal) ===
return (
<div className="space-y-4">
<div className="flex gap-3">
<div className="flex flex-col gap-1.5 shrink-0 min-w-[90px]">
<p className="text-xs text-muted-foreground font-medium mb-1">Componentes</p>
{paletteContent}
</div>
<div className="flex-1 flex flex-col gap-2">
{toolbar(false)}
<div className="flex-1 border border-border rounded-lg overflow-hidden bg-[#0a0a0a]">
{svgCanvas(false)}
</div>
</div>
</div>
{probeTable}
{simResult && !simResult.success && simResult.error && components.length > 0 && (
<p className="text-xs text-amber-500">{simResult.error}</p>
)}
<div className="text-xs text-muted-foreground space-y-1">
<p>Arrastra desde un terminal () a otro para conectar. Clic derecho para eliminar.</p>
<p>Selecciona un componente para rotarlo o cambiar su valor.</p>
</div>
{fullscreenOverlay}
</div>
);
}

View File

@@ -0,0 +1,361 @@
import {
ElectronicCircuitState,
ElectronicComponent,
SimulationResult,
} from '@/types/electronics';
/**
* Simplified DC circuit simulator using node-voltage analysis.
*
* Supports: voltage sources, resistors, ground, switches, LEDs, NMOS/PMOS (ideal).
* MOSFETs are modeled as ideal switches:
* NMOS: ON (low Rds) when Vgs > threshold (2V), OFF (high Rds) otherwise
* PMOS: ON when Vgs < -threshold (-2V), OFF otherwise
*
* Uses iterative approach for nonlinear elements (transistors):
* 1. Assume all transistors OFF
* 2. Solve linear circuit
* 3. Check transistor states, update if changed
* 4. Repeat until stable (max 10 iterations)
*/
const VTH = 2; // MOSFET threshold voltage
const R_ON = 1; // MOSFET ON resistance (Ω)
const R_OFF = 1e9; // MOSFET OFF resistance (Ω)
const R_LED = 100; // LED effective resistance when forward biased
const V_LED = 1.8; // LED forward voltage drop
export function simulateElectronics(circuit: ElectronicCircuitState): SimulationResult {
const { components, wires } = circuit;
if (components.length === 0) {
return { success: false, error: 'No hay componentes', nodeVoltages: new Map(), branchCurrents: new Map(), meterReadings: new Map() };
}
// Build net list: group connected terminals into nodes
const terminalToNode = new Map<string, string>();
let nodeCounter = 0;
function getTerminalId(compId: string, terminal: string): string {
return `${compId}:${terminal}`;
}
function findNode(termId: string): string {
let node = terminalToNode.get(termId);
if (!node) {
node = `n${nodeCounter++}`;
terminalToNode.set(termId, node);
}
return node;
}
function mergeNodes(a: string, b: string) {
const nodeA = findNode(a);
const nodeB = findNode(b);
if (nodeA === nodeB) return;
// Replace all nodeB references with nodeA
for (const [key, val] of terminalToNode.entries()) {
if (val === nodeB) terminalToNode.set(key, nodeA);
}
}
// Initialize all component terminals
for (const comp of components) {
const terminals = getComponentTerminals(comp.type);
for (const t of terminals) {
findNode(getTerminalId(comp.id, t));
}
}
// Process wires to merge nodes
for (const wire of wires) {
mergeNodes(wire.from, wire.to);
}
// Find ground node
let groundNode: string | null = null;
for (const comp of components) {
if (comp.type === 'ground') {
groundNode = findNode(getTerminalId(comp.id, 'gnd'));
break;
}
}
if (!groundNode) {
return { success: false, error: 'Necesitas un nodo de tierra (GND)', nodeVoltages: new Map(), branchCurrents: new Map(), meterReadings: new Map() };
}
// Get unique node names (excluding ground)
const allNodes = [...new Set(terminalToNode.values())];
const nodeList = allNodes.filter((n) => n !== groundNode);
const nodeIndex = new Map<string, number>();
nodeList.forEach((n, i) => nodeIndex.set(n, i));
const N = nodeList.length;
if (N === 0) {
return { success: true, nodeVoltages: new Map(), branchCurrents: new Map(), meterReadings: new Map() };
}
// Count voltage sources for MNA
const voltageSources = components.filter((c) => c.type === 'voltage-source');
const M = voltageSources.length;
const size = N + M;
// Transistor states (start all OFF)
const mosfetState = new Map<string, boolean>();
for (const comp of components) {
if (comp.type === 'nmos' || comp.type === 'pmos') {
mosfetState.set(comp.id, false);
}
}
let voltages: Float64Array = new Float64Array(size);
let converged = false;
for (let iter = 0; iter < 10; iter++) {
// Build MNA matrix: [G B; C D] * [v; i] = [I; E]
const A = Array.from({ length: size }, () => new Float64Array(size));
const b = new Float64Array(size);
function nodeIdx(termId: string): number {
const node = terminalToNode.get(termId);
if (!node || node === groundNode) return -1;
return nodeIndex.get(node) ?? -1;
}
// Stamp resistor: G(i,i) += 1/R, G(j,j) += 1/R, G(i,j) -= 1/R, G(j,i) -= 1/R
function stampResistor(termA: string, termB: string, R: number) {
const i = nodeIdx(termA);
const j = nodeIdx(termB);
const g = 1 / R;
if (i >= 0) A[i][i] += g;
if (j >= 0) A[j][j] += g;
if (i >= 0 && j >= 0) { A[i][j] -= g; A[j][i] -= g; }
}
// Stamp voltage source
function stampVoltageSource(posTerminal: string, negTerminal: string, voltage: number, vsIdx: number) {
const i = nodeIdx(posTerminal);
const j = nodeIdx(negTerminal);
const k = N + vsIdx;
if (i >= 0) { A[i][k] += 1; A[k][i] += 1; }
if (j >= 0) { A[j][k] -= 1; A[k][j] -= 1; }
b[k] = voltage;
}
// Process each component
let vsCount = 0;
for (const comp of components) {
switch (comp.type) {
case 'resistor': {
const R = comp.value ?? 1000;
stampResistor(getTerminalId(comp.id, 'a'), getTerminalId(comp.id, 'b'), R);
break;
}
case 'voltage-source': {
const V = comp.value ?? 5;
stampVoltageSource(
getTerminalId(comp.id, 'pos'),
getTerminalId(comp.id, 'neg'),
V, vsCount++
);
break;
}
case 'capacitor': {
// In DC steady state, capacitor = open circuit (very high R)
// But we model it so voltage across it can be measured
stampResistor(getTerminalId(comp.id, 'a'), getTerminalId(comp.id, 'b'), R_OFF);
break;
}
case 'led': {
// Simplified: resistor + voltage drop (model as resistor for now)
stampResistor(getTerminalId(comp.id, 'anode'), getTerminalId(comp.id, 'cathode'), R_LED);
break;
}
case 'switch': {
// Switches modeled as low resistance when "on" (value=1), high when "off" (value=0)
const isOn = (comp.value ?? 0) > 0;
stampResistor(getTerminalId(comp.id, 'a'), getTerminalId(comp.id, 'b'), isOn ? 0.01 : R_OFF);
break;
}
case 'nmos': {
const isOn = mosfetState.get(comp.id) ?? false;
const R = isOn ? R_ON : R_OFF;
stampResistor(getTerminalId(comp.id, 'drain'), getTerminalId(comp.id, 'source'), R);
break;
}
case 'pmos': {
const isOn = mosfetState.get(comp.id) ?? false;
const R = isOn ? R_ON : R_OFF;
stampResistor(getTerminalId(comp.id, 'drain'), getTerminalId(comp.id, 'source'), R);
break;
}
case 'voltmeter': {
// Voltmeter = very high resistance (doesn't affect circuit)
stampResistor(getTerminalId(comp.id, 'pos'), getTerminalId(comp.id, 'neg'), 1e9);
break;
}
case 'ammeter': {
// Ammeter = very low resistance (passes all current)
stampResistor(getTerminalId(comp.id, 'a'), getTerminalId(comp.id, 'b'), 0.001);
break;
}
case 'ground':
break;
}
}
// Solve A * x = b using Gaussian elimination
const solved = solveLinearSystem(A, b, size);
if (!solved) {
return { success: false, error: 'El circuito no tiene solución (¿cortocircuito?)', nodeVoltages: new Map(), branchCurrents: new Map(), meterReadings: new Map() };
}
voltages = solved;
// Update MOSFET states based on solved voltages
let changed = false;
for (const comp of components) {
if (comp.type !== 'nmos' && comp.type !== 'pmos') continue;
const gateNode = terminalToNode.get(getTerminalId(comp.id, 'gate'));
const sourceNode = terminalToNode.get(getTerminalId(comp.id, 'source'));
const vGate = getNodeVoltage(gateNode, groundNode, nodeIndex, voltages);
const vSource = getNodeVoltage(sourceNode, groundNode, nodeIndex, voltages);
const vgs = vGate - vSource;
let shouldBeOn = false;
if (comp.type === 'nmos') shouldBeOn = vgs > VTH;
if (comp.type === 'pmos') shouldBeOn = vgs < -VTH;
const wasOn = mosfetState.get(comp.id) ?? false;
if (shouldBeOn !== wasOn) {
mosfetState.set(comp.id, shouldBeOn);
changed = true;
}
}
if (!changed) {
converged = true;
break;
}
}
if (!converged && mosfetState.size > 0) {
// Still usable, just warn
}
// Build result maps
const nodeVoltages = new Map<string, number>();
for (const [termId, node] of terminalToNode.entries()) {
const v = getNodeVoltage(node, groundNode!, nodeIndex, voltages);
nodeVoltages.set(termId, Math.round(v * 1000) / 1000);
}
const branchCurrents = new Map<string, number>();
// Calculate currents through resistors
for (const comp of components) {
if (comp.type === 'resistor') {
const va = nodeVoltages.get(getTerminalId(comp.id, 'a')) ?? 0;
const vb = nodeVoltages.get(getTerminalId(comp.id, 'b')) ?? 0;
const R = comp.value ?? 1000;
branchCurrents.set(comp.id, Math.round(((va - vb) / R) * 10000) / 10000);
}
}
// Compute meter readings
const meterReadings = new Map<string, { value: number; unit: string }>();
for (const comp of components) {
if (comp.type === 'voltmeter') {
const vPos = nodeVoltages.get(getTerminalId(comp.id, 'pos')) ?? 0;
const vNeg = nodeVoltages.get(getTerminalId(comp.id, 'neg')) ?? 0;
const diff = Math.round((vPos - vNeg) * 1000) / 1000;
meterReadings.set(comp.id, { value: diff, unit: 'V' });
}
if (comp.type === 'ammeter') {
const vA = nodeVoltages.get(getTerminalId(comp.id, 'a')) ?? 0;
const vB = nodeVoltages.get(getTerminalId(comp.id, 'b')) ?? 0;
const current = Math.round(((vA - vB) / 0.001) * 10000) / 10000;
meterReadings.set(comp.id, { value: Math.abs(current) * 1000, unit: 'mA' });
}
}
return { success: true, nodeVoltages, branchCurrents, meterReadings };
}
function getNodeVoltage(
node: string | undefined,
groundNode: string,
nodeIndex: Map<string, number>,
voltages: Float64Array
): number {
if (!node || node === groundNode) return 0;
const idx = nodeIndex.get(node);
if (idx === undefined) return 0;
return voltages[idx];
}
function getComponentTerminals(type: string): string[] {
switch (type) {
case 'voltage-source': return ['pos', 'neg'];
case 'resistor': return ['a', 'b'];
case 'capacitor': return ['a', 'b'];
case 'led': return ['anode', 'cathode'];
case 'switch': return ['a', 'b'];
case 'ground': return ['gnd'];
case 'nmos': return ['gate', 'drain', 'source'];
case 'pmos': return ['gate', 'drain', 'source'];
case 'voltmeter': return ['pos', 'neg'];
case 'ammeter': return ['a', 'b'];
default: return [];
}
}
/** Gaussian elimination with partial pivoting */
function solveLinearSystem(A: Float64Array[], b: Float64Array, n: number): Float64Array | null {
// Augmented matrix
const aug = A.map((row, i) => {
const r = new Float64Array(n + 1);
r.set(row);
r[n] = b[i];
return r;
});
for (let col = 0; col < n; col++) {
// Pivot
let maxRow = col;
let maxVal = Math.abs(aug[col][col]);
for (let row = col + 1; row < n; row++) {
if (Math.abs(aug[row][col]) > maxVal) {
maxVal = Math.abs(aug[row][col]);
maxRow = row;
}
}
if (maxVal < 1e-12) continue; // singular, skip
[aug[col], aug[maxRow]] = [aug[maxRow], aug[col]];
// Eliminate
for (let row = col + 1; row < n; row++) {
const factor = aug[row][col] / aug[col][col];
for (let j = col; j <= n; j++) {
aug[row][j] -= factor * aug[col][j];
}
}
}
// Back substitution
const x = new Float64Array(n);
for (let row = n - 1; row >= 0; row--) {
if (Math.abs(aug[row][row]) < 1e-12) {
x[row] = 0;
continue;
}
let sum = aug[row][n];
for (let j = row + 1; j < n; j++) {
sum -= aug[row][j] * x[j];
}
x[row] = sum / aug[row][row];
}
return x;
}

View File

@@ -0,0 +1,257 @@
import { Challenge } from '@/types/challenge';
import { CircuitBuilderContent, TruthTableRow } from '@/types/circuit';
function circuitChallenge(
id: string,
nodeId: string,
title: string,
description: string,
availableGates: CircuitBuilderContent['availableGates'],
inputLabels: string[],
outputLabels: string[],
truthTable: TruthTableRow[],
difficulty: 1 | 2 | 3 | 4 | 5,
hints: string[] = [],
explanation?: string,
maxGates?: number
): Challenge {
return {
id: `${nodeId}/${id}`,
nodeId,
title,
description,
difficulty,
type: 'circuit-builder',
hints,
xpReward: difficulty * 25,
explanation,
content: {
type: 'circuit-builder',
availableGates,
inputLabels,
outputLabels,
truthTable,
maxGates,
},
};
}
// === LOGIC GATES NODE ===
export const logicGateChallenges: Challenge[] = [
circuitChallenge(
'lg-01', 'electronics.logic-gates',
'Puerta AND',
'Construye un circuito donde la salida Y sea 1 solo cuando AMBAS entradas A y B sean 1.',
['AND'],
['A', 'B'], ['Y'],
[
{ inputs: [false, false], outputs: [false] },
{ inputs: [false, true], outputs: [false] },
{ inputs: [true, false], outputs: [false] },
{ inputs: [true, true], outputs: [true] },
],
2,
['Necesitas solo una puerta AND'],
'La puerta AND produce 1 solo cuando TODAS sus entradas son 1.\n\nTabla de verdad:\n A | B | Y\n 0 | 0 | 0\n 0 | 1 | 0\n 1 | 0 | 0\n 1 | 1 | 1\n\nAnalogia: piensa en dos interruptores en serie. La luz solo se enciende si AMBOS están encendidos.'
),
circuitChallenge(
'lg-02', 'electronics.logic-gates',
'Puerta OR',
'Construye un circuito donde la salida Y sea 1 cuando AL MENOS UNA entrada sea 1.',
['OR'],
['A', 'B'], ['Y'],
[
{ inputs: [false, false], outputs: [false] },
{ inputs: [false, true], outputs: [true] },
{ inputs: [true, false], outputs: [true] },
{ inputs: [true, true], outputs: [true] },
],
2,
['Necesitas solo una puerta OR'],
'La puerta OR produce 1 cuando AL MENOS UNA entrada es 1.\n\nTabla de verdad:\n A | B | Y\n 0 | 0 | 0\n 0 | 1 | 1\n 1 | 0 | 1\n 1 | 1 | 1\n\nAnalogia: dos interruptores en paralelo. La luz se enciende si CUALQUIERA está encendido.'
),
circuitChallenge(
'lg-03', 'electronics.logic-gates',
'Puerta NOT',
'Construye un circuito donde la salida Y sea lo opuesto a la entrada A.',
['NOT'],
['A'], ['Y'],
[
{ inputs: [false], outputs: [true] },
{ inputs: [true], outputs: [false] },
],
2,
['La puerta NOT invierte la señal'],
'La puerta NOT (inversora) invierte la señal: si entra 1, sale 0 y viceversa.\n\n A | Y\n 0 | 1\n 1 | 0\n\nEs la puerta más simple. Solo tiene una entrada y una salida.'
),
circuitChallenge(
'lg-04', 'electronics.logic-gates',
'Puerta NAND',
'Construye un circuito con una puerta NAND. La salida Y es 0 solo cuando A y B son ambas 1.',
['NAND'],
['A', 'B'], ['Y'],
[
{ inputs: [false, false], outputs: [true] },
{ inputs: [false, true], outputs: [true] },
{ inputs: [true, false], outputs: [true] },
{ inputs: [true, true], outputs: [false] },
],
2,
['NAND = NOT AND. Es el opuesto de AND.'],
'NAND significa "NOT AND" — es una puerta AND seguida de un NOT.\n\nTabla de verdad (opuesta a AND):\n A | B | Y\n 0 | 0 | 1\n 0 | 1 | 1\n 1 | 0 | 1\n 1 | 1 | 0\n\nDato clave: la NAND es una "puerta universal" — con solo puertas NAND puedes construir CUALQUIER otra puerta lógica.'
),
circuitChallenge(
'lg-05', 'electronics.logic-gates',
'Puerta XOR',
'Construye un circuito donde Y sea 1 cuando A y B sean DIFERENTES.',
['XOR'],
['A', 'B'], ['Y'],
[
{ inputs: [false, false], outputs: [false] },
{ inputs: [false, true], outputs: [true] },
{ inputs: [true, false], outputs: [true] },
{ inputs: [true, true], outputs: [false] },
],
2,
['XOR = "exclusive OR". Es 1 cuando las entradas son distintas.'],
'XOR (OR exclusivo) produce 1 cuando las entradas son DIFERENTES.\n\n A | B | Y\n 0 | 0 | 0\n 0 | 1 | 1\n 1 | 0 | 1\n 1 | 1 | 0\n\nA diferencia de OR, XOR da 0 cuando ambas son 1. Es fundamental para la aritmética binaria (suma sin acarreo).'
),
circuitChallenge(
'lg-06', 'electronics.logic-gates',
'NOT con NAND',
'Construye un inversor (NOT) usando SOLO puertas NAND.',
['NAND'],
['A'], ['Y'],
[
{ inputs: [false], outputs: [true] },
{ inputs: [true], outputs: [false] },
],
3,
['Conecta ambas entradas de la NAND a la misma señal'],
'Si conectas ambas entradas de una NAND a la misma señal:\n\nNAND(A, A) = NOT(A AND A) = NOT(A)\n\nEsto demuestra que NAND puede funcionar como NOT. Es el primer paso para demostrar que NAND es universal.'
),
circuitChallenge(
'lg-07', 'electronics.logic-gates',
'AND con NAND',
'Construye una puerta AND usando SOLO puertas NAND.',
['NAND'],
['A', 'B'], ['Y'],
[
{ inputs: [false, false], outputs: [false] },
{ inputs: [false, true], outputs: [false] },
{ inputs: [true, false], outputs: [false] },
{ inputs: [true, true], outputs: [true] },
],
3,
['Primero haz NAND(A,B), luego invierte el resultado con otra NAND'],
'NAND(A,B) da NOT(AND(A,B)). Si invertimos eso:\n\nNOT(NAND(A,B)) = NOT(NOT(AND(A,B))) = AND(A,B)\n\nNecesitas 2 puertas NAND:\n1. NAND(A, B) → resultado intermedio\n2. NAND(resultado, resultado) → AND(A, B)',
3
),
circuitChallenge(
'lg-08', 'electronics.logic-gates',
'OR con NAND',
'Construye una puerta OR usando SOLO puertas NAND.',
['NAND'],
['A', 'B'], ['Y'],
[
{ inputs: [false, false], outputs: [false] },
{ inputs: [false, true], outputs: [true] },
{ inputs: [true, false], outputs: [true] },
{ inputs: [true, true], outputs: [true] },
],
3,
['Primero invierte cada entrada con NAND, luego NAND los resultados'],
'Por la ley de De Morgan: OR(A,B) = NOT(AND(NOT(A), NOT(B)))\n\nCon NANDs:\n1. NAND(A,A) = NOT(A)\n2. NAND(B,B) = NOT(B)\n3. NAND(NOT(A), NOT(B)) = NOT(AND(NOT(A),NOT(B))) = OR(A,B)\n\nNecesitas 3 puertas NAND.',
3
),
];
// === COMBINATIONAL CIRCUITS NODE ===
export const combinationalChallenges: Challenge[] = [
circuitChallenge(
'cc-01', 'electronics.combinational',
'Medio Sumador (Half Adder)',
'Construye un circuito que sume dos bits A y B. Produce dos salidas: Sum (suma) y Carry (acarreo).',
['AND', 'XOR'],
['A', 'B'], ['Sum', 'Carry'],
[
{ inputs: [false, false], outputs: [false, false] },
{ inputs: [false, true], outputs: [true, false] },
{ inputs: [true, false], outputs: [true, false] },
{ inputs: [true, true], outputs: [false, true] },
],
3,
['Sum = A XOR B, Carry = A AND B'],
'El medio sumador (half adder) suma dos bits:\n\n A | B | Sum | Carry\n 0 | 0 | 0 | 0\n 0 | 1 | 1 | 0\n 1 | 0 | 1 | 0\n 1 | 1 | 0 | 1\n\n• Sum (suma): es 1 cuando A y B son diferentes → XOR\n• Carry (acarreo): es 1 cuando ambos son 1 → AND\n\nEste circuito es la base de toda la aritmética en computadoras.'
),
circuitChallenge(
'cc-02', 'electronics.combinational',
'Multiplexor 2:1',
'Construye un MUX: cuando Sel=0, Y=D0. Cuando Sel=1, Y=D1.',
['AND', 'OR', 'NOT'],
['D0', 'D1', 'Sel'], ['Y'],
[
{ inputs: [false, false, false], outputs: [false] },
{ inputs: [false, false, true], outputs: [false] },
{ inputs: [false, true, false], outputs: [false] },
{ inputs: [false, true, true], outputs: [true] },
{ inputs: [true, false, false], outputs: [true] },
{ inputs: [true, false, true], outputs: [false] },
{ inputs: [true, true, false], outputs: [true] },
{ inputs: [true, true, true], outputs: [true] },
],
3,
['Y = (D0 AND NOT Sel) OR (D1 AND Sel)'],
'Un multiplexor (MUX) selecciona una de varias entradas según una señal de selección.\n\nMUX 2:1:\n• Si Sel=0 → Y = D0\n• Si Sel=1 → Y = D1\n\nFórmula: Y = (D0 · NOT(Sel)) + (D1 · Sel)\n\nNecesitas: 1 NOT, 2 AND, 1 OR'
),
circuitChallenge(
'cc-03', 'electronics.combinational',
'Detector de Paridad',
'Construye un circuito que detecte si el número de 1s entre A y B es impar (paridad impar).',
['XOR'],
['A', 'B'], ['P'],
[
{ inputs: [false, false], outputs: [false] },
{ inputs: [false, true], outputs: [true] },
{ inputs: [true, false], outputs: [true] },
{ inputs: [true, true], outputs: [false] },
],
3,
['La paridad impar de 2 bits es simplemente XOR'],
'La paridad es un método para detectar errores en transmisión de datos.\n\nParidad impar: P=1 si hay un número impar de 1s en la entrada.\n\nPara 2 bits, resulta que A XOR B ya lo calcula:\n 0 XOR 0 = 0 (cero 1s, par)\n 0 XOR 1 = 1 (un 1, impar)\n 1 XOR 0 = 1 (un 1, impar)\n 1 XOR 1 = 0 (dos 1s, par)'
),
circuitChallenge(
'cc-04', 'electronics.combinational',
'Half Adder con NAND',
'Construye un medio sumador usando SOLO puertas NAND.',
['NAND'],
['A', 'B'], ['Sum', 'Carry'],
[
{ inputs: [false, false], outputs: [false, false] },
{ inputs: [false, true], outputs: [true, false] },
{ inputs: [true, false], outputs: [true, false] },
{ inputs: [true, true], outputs: [false, true] },
],
4,
['Carry = NOT(NAND(A,B)). Para Sum necesitas construir XOR con NANDs.'],
'Desafío avanzado: construir un half adder completo solo con NANDs.\n\nRecuerda:\n• Carry = AND(A,B) = NAND(NAND(A,B), NAND(A,B))\n• Sum = XOR(A,B) — necesitas construir XOR con NANDs\n\nXOR con NANDs:\n t = NAND(A,B)\n Sum = NAND(NAND(A,t), NAND(B,t))\n\nTotal: 5 puertas NAND',
5
),
];
export const allCircuitChallenges: Challenge[] = [
...logicGateChallenges,
...combinationalChallenges,
];

View File

@@ -0,0 +1,479 @@
import { Challenge } from '@/types/challenge';
import { ElectronicsContent } from '@/types/electronics';
function elecChallenge(
id: string,
nodeId: string,
title: string,
description: string,
content: ElectronicsContent,
difficulty: 1 | 2 | 3 | 4 | 5,
hints: string[] = [],
explanation?: string
): Challenge {
return {
id: `${nodeId}/${id}`,
nodeId,
title,
description,
difficulty,
type: 'electronics-lab',
hints,
xpReward: difficulty * 25,
explanation,
content,
};
}
function mathElecChallenge(
id: string,
nodeId: string,
title: string,
problem: string,
answer: number,
difficulty: 1 | 2 | 3 | 4 | 5,
hints: string[] = [],
tolerance = 0,
explanation?: string
): Challenge {
return {
id: `${nodeId}/${id}`,
nodeId,
title,
description: problem,
difficulty,
type: 'math-input',
hints,
xpReward: difficulty * 25,
explanation,
content: {
type: 'math-input',
problem,
answer: { type: 'numeric', value: answer, tolerance },
},
};
}
// =========================================================================
// ELECTRICITY BASICS
// =========================================================================
export const electricityBasicsChallenges: Challenge[] = [
elecChallenge(
'eb-01', 'electronics.basics',
'Ley de Ohm',
'Conecta una fuente de 5V a una resistencia de 1000Ω y a tierra. Usa el voltímetro para medir el voltaje en la fuente.',
{
type: 'electronics-lab',
availableComponents: ['voltage-source', 'resistor', 'ground', 'voltmeter', 'ammeter'],
probes: [
{ type: 'voltmeter', expected: 5, tolerance: 0.5, label: 'Voltaje en la fuente', unit: 'V' },
],
},
2,
['Conecta: Fuente(+) → Resistencia → Tierra. Fuente(-) → Tierra.'],
'La Ley de Ohm es la base de toda la electrónica:\n\nV = I × R\n\nDonde:\n• V = Voltaje (Voltios, V)\n• I = Corriente (Amperios, A)\n• R = Resistencia (Ohmios, Ω)\n\nSi tienes una fuente de 5V y una resistencia de 1000Ω:\nI = V/R = 5/1000 = 0.005A = 5mA\n\nInstrumentos de medida:\n• Voltímetro (V): mide voltaje ENTRE dos puntos. Se conecta en PARALELO.\n• Amperímetro (A): mide corriente QUE PASA por un punto. Se conecta en SERIE.\n\nPulsa "Simular" cuando tu circuito esté listo.'
),
mathElecChallenge(
'eb-02', 'electronics.basics',
'Calcula la corriente',
'Una fuente de 9V alimenta una resistencia de 4500Ω. ¿Cuántos miliamperios (mA) circulan?',
2, 2,
['I = V/R = 9/4500. Convierte a mA multiplicando por 1000.'],
0.1,
'Ley de Ohm despejada para corriente:\n\nI = V / R\n\nEjemplo: con 12V y 3000Ω → I = 12/3000 = 0.004A = 4mA\n\nRecuerda: 1A = 1000mA'
),
elecChallenge(
'eb-03', 'electronics.basics',
'Divisor de Voltaje',
'Crea un divisor de voltaje con dos resistencias de 1000Ω alimentado por 5V. El punto medio debe tener 2.5V.',
{
type: 'electronics-lab',
availableComponents: ['voltage-source', 'resistor', 'ground', 'voltmeter'],
probes: [
{ type: 'voltmeter', expected: 2.5, tolerance: 0.5, label: 'Voltaje en punto medio', unit: 'V' },
],
},
2,
['Conecta: Fuente(+) → R1 → punto medio → R2 → GND'],
'Un divisor de voltaje usa dos resistencias en serie:\n\n Vout = Vin × R2 / (R1 + R2)\n\nCon R1 = R2 = 1kΩ y Vin = 5V:\n Vout = 5 × 1000 / (1000 + 1000) = 2.5V\n\nEstructura:\n 5V → [R1] → punto medio (2.5V) → [R2] → GND'
),
mathElecChallenge(
'eb-04', 'electronics.basics',
'Calcula la resistencia',
'Necesitas que circulen exactamente 20mA con una fuente de 12V. ¿Qué resistencia necesitas en ohmios?',
600, 2,
['R = V/I. Convierte 20mA a amperios primero: 0.020A'],
0,
'Ley de Ohm despejada para resistencia:\n\nR = V / I\n\nEjemplo: con 10V y 25mA → R = 10/0.025 = 400Ω'
),
];
// =========================================================================
// SERIES & PARALLEL
// =========================================================================
export const seriesParallelChallenges: Challenge[] = [
mathElecChallenge(
'sp-01', 'electronics.series-parallel',
'Resistencias en Serie',
'Tres resistencias de 100Ω, 220Ω y 330Ω están en serie. ¿Cuál es la resistencia total en ohmios?',
650, 2,
['En serie se suman: Rtotal = R1 + R2 + R3'],
0,
'Resistencias en SERIE — se suman:\n\nRtotal = R1 + R2 + R3 + ...\n\nLa corriente es la MISMA en cada resistencia.\nEl voltaje se REPARTE entre ellas.\n\nEjemplo: 150Ω + 270Ω = 420Ω'
),
mathElecChallenge(
'sp-02', 'electronics.series-parallel',
'Resistencias en Paralelo',
'Dos resistencias de 1000Ω están en paralelo. ¿Cuál es la resistencia total?',
500, 2,
['1/Rtotal = 1/R1 + 1/R2. Con dos iguales: Rtotal = R/2'],
0,
'Resistencias en PARALELO — inverso de la suma de inversos:\n\n1/Rtotal = 1/R1 + 1/R2 + ...\n\nCon dos resistencias iguales: Rtotal = R/2\n\nEl voltaje es el MISMO en cada resistencia.\nLa corriente se REPARTE.\n\nEjemplo: dos de 470Ω → Rtotal = 470/2 = 235Ω'
),
mathElecChallenge(
'sp-03', 'electronics.series-parallel',
'Paralelo desigual',
'Dos resistencias de 300Ω y 600Ω en paralelo. ¿Cuál es la resistencia total?',
200, 3,
['1/Rtotal = 1/300 + 1/600 = 3/600. Rtotal = 600/3'],
0,
'Con resistencias distintas:\n\n1/Rtotal = 1/R1 + 1/R2\n\nTruco para dos resistencias: Rtotal = (R1 × R2) / (R1 + R2)\n\nEjemplo: 200Ω ∥ 400Ω = (200×400)/(200+400) = 80000/600 ≈ 133Ω'
),
elecChallenge(
'sp-04', 'electronics.series-parallel',
'Divisor 3:1',
'Crea un divisor de voltaje que produzca 1.25V a partir de 5V. Usa una resistencia de 3000Ω y otra de 1000Ω.',
{
type: 'electronics-lab',
availableComponents: ['voltage-source', 'resistor', 'ground', 'voltmeter'],
probes: [
{ type: 'voltmeter', expected: 1.25, tolerance: 0.3, label: 'Voltaje de salida', unit: 'V' },
],
},
3,
['Vout = 5 × R2/(R1+R2). Pon R1=3kΩ arriba y R2=1kΩ abajo.'],
'Divisor con proporción 3:1:\n\nVout = Vin × R2 / (R1 + R2)\nVout = 5 × 1000 / (3000 + 1000) = 5 × 1/4 = 1.25V\n\nR1 (la de arriba) "absorbe" más voltaje porque es más grande.'
),
];
// =========================================================================
// CAPACITORS
// =========================================================================
export const capacitorChallenges: Challenge[] = [
mathElecChallenge(
'cap-01', 'electronics.capacitors',
'Carga del condensador',
'Un condensador de 100µF se carga a través de una resistencia de 10kΩ. ¿Cuál es la constante de tiempo τ (tau) en segundos?',
1, 2,
['τ = R × C. Cuidado con las unidades: µF = 10⁻⁶F, kΩ = 10³Ω'],
0.01,
'Un condensador almacena energía en un campo eléctrico.\n\nLa constante de tiempo τ (tau) determina la velocidad de carga:\n\nτ = R × C\n\nEjemplo: con 5kΩ y 200µF → τ = 5000 × 0.0002 = 1s\n\n• Después de 1τ, el condensador está al 63% de carga\n• Después de 5τ, prácticamente al 100%'
),
mathElecChallenge(
'cap-02', 'electronics.capacitors',
'Condensadores en paralelo',
'Tres condensadores de 10µF, 22µF y 47µF en paralelo. ¿Cuál es la capacitancia total en µF?',
79, 2,
['En paralelo se suman: Ctotal = C1 + C2 + C3'],
0,
'Los condensadores en paralelo se SUMAN (al revés que las resistencias):\n\nCtotal = C1 + C2 + C3 + ...\n\nEjemplo: 15µF + 33µF = 48µF\n\nEn paralelo: más capacitancia = más almacenamiento.\nEn serie: 1/Ctotal = 1/C1 + 1/C2 + ... (se reduce, opuesto a resistencias).'
),
mathElecChallenge(
'cap-03', 'electronics.capacitors',
'Frecuencia de corte RC',
'Un filtro RC tiene R=1kΩ y C=1µF. ¿Cuál es la frecuencia de corte en Hz? (redondeada)',
159, 3,
['f = 1 / (2π × R × C). R en Ω, C en F.'],
2,
'Un circuito RC forma un filtro:\n\n• Filtro paso-bajo: deja pasar frecuencias bajas\n• Filtro paso-alto: deja pasar frecuencias altas\n\nLa frecuencia de corte (-3dB):\n\nfc = 1 / (2π × R × C)\n\nEjemplo: con R=2.2kΩ y C=0.1µF → fc = 1/(2π × 2200 × 0.0000001) ≈ 723Hz\n\nFundamental en audio (filtros de sintetizadores) y comunicaciones.'
),
elecChallenge(
'cap-04', 'electronics.capacitors',
'Condensador bloquea DC',
'Conecta: Fuente 5V → Resistencia 1kΩ → Condensador → Resistencia 1kΩ → GND. Mide el voltaje con el voltímetro en la segunda resistencia (después del condensador). ¿Cuánto marca?',
{
type: 'electronics-lab',
availableComponents: ['voltage-source', 'resistor', 'capacitor', 'ground', 'voltmeter'],
probes: [
{ type: 'voltmeter', expected: 0, tolerance: 0.1, label: 'Voltaje después del condensador', unit: 'V' },
],
},
2,
['En DC, el condensador no deja pasar corriente. La segunda resistencia ve 0V.'],
'En corriente continua (DC), un condensador cargado actúa como un CIRCUITO ABIERTO.\n\nNo deja pasar corriente DC una vez cargado. Por eso:\n• Todo el voltaje cae entre la fuente y el condensador\n• Después del condensador: 0V, 0A\n\nEsto es útil para:\n• Bloquear DC y dejar pasar AC (condensador de acoplo)\n• Filtrar ruido de alta frecuencia (condensador de bypass)\n\nConecta el voltímetro en paralelo con la segunda resistencia para comprobarlo.'
),
elecChallenge(
'cap-05', 'electronics.capacitors',
'Condensador de bypass',
'Añade un condensador de 100µF en paralelo con una resistencia de carga de 1kΩ alimentada por 5V. Mide el voltaje con el voltímetro en la resistencia. Debe ser 5V (el condensador estabiliza el voltaje).',
{
type: 'electronics-lab',
availableComponents: ['voltage-source', 'resistor', 'capacitor', 'ground', 'voltmeter'],
probes: [
{ type: 'voltmeter', expected: 5, tolerance: 0.5, label: 'Voltaje en la carga', unit: 'V' },
],
},
3,
['Fuente(+) → R → punto de carga → GND. Condensador en paralelo: un terminal al punto de carga, otro a GND.'],
'Un condensador de bypass se coloca en PARALELO con la carga.\n\nEn DC actúa como circuito abierto, así que no cambia el voltaje estático.\nPero en la práctica absorbe picos y fluctuaciones de voltaje.\n\nCircuito:\n 5V → [R] → punto ─┬─ [Carga R] → GND\n └─ [C bypass] → GND\n\nEl voltímetro en el punto de carga debe leer el voltaje completo de la fuente (menos la caída en R de alimentación).'
),
];
// =========================================================================
// DIODES & LEDs
// =========================================================================
export const diodeChallenges: Challenge[] = [
elecChallenge(
'diode-01', 'electronics.diodes',
'LED con Resistencia',
'Conecta un LED con una resistencia de protección a una fuente de 5V.',
{
type: 'electronics-lab',
availableComponents: ['voltage-source', 'resistor', 'led', 'ground', 'voltmeter', 'ammeter'],
probes: [
{ type: 'voltmeter', expected: 5, tolerance: 0.5, label: 'Voltaje de la fuente', unit: 'V' },
],
},
2,
['Conecta: Fuente(+) → Resistencia → LED(ánodo→cátodo) → GND'],
'Un LED emite luz cuando la corriente fluye del ánodo (+) al cátodo (-).\n\nNUNCA conectes un LED directamente a la fuente sin resistencia — se quema.\n\nCircuito típico:\n Fuente(+) → [Resistencia] → LED(ánodo→cátodo) → GND\n\nLa resistencia limita la corriente:\n R = (Vfuente - Vled) / I\n R = (5 - 1.8) / 0.020 = 160Ω → usa 220Ω'
),
mathElecChallenge(
'diode-02', 'electronics.diodes',
'Resistencia para LED',
'Tienes una fuente de 9V, un LED con caída de 2V y quieres 15mA. ¿Qué resistencia necesitas? (en Ω, redondeada)',
467, 2,
['R = (Vfuente - Vled) / I = (9-2) / 0.015'],
5,
'Para calcular la resistencia de protección de un LED:\n\nR = (Vfuente - Vled) / I_deseada\n\nEjemplo: con 5V, LED de 1.8V, y 20mA → R = (5-1.8)/0.020 = 160Ω → usa 180Ω\n\nEn la práctica se elige el valor comercial más cercano por arriba.'
),
{
id: 'electronics.diodes/diode-03',
nodeId: 'electronics.diodes',
title: 'Polarización del diodo',
description: '¿En qué dirección debe fluir la corriente para que un diodo conduzca?',
difficulty: 2,
type: 'multiple-choice',
hints: ['El diodo conduce del ánodo al cátodo'],
xpReward: 50,
explanation: 'Un diodo es como una válvula: solo deja pasar corriente en UN sentido.\n\n• Polarización directa: corriente fluye del ÁNODO (+) al CÁTODO (-) → CONDUCE\n• Polarización inversa: corriente intenta ir del cátodo al ánodo → BLOQUEADO\n\nEl diodo necesita una tensión mínima (Vf ≈ 0.7V para silicio, ≈ 1.8V para LED) para empezar a conducir.',
content: {
type: 'multiple-choice',
question: '¿En qué sentido conduce un diodo?',
options: [
'Del ánodo (+) al cátodo (-)',
'Del cátodo (-) al ánodo (+)',
'En ambas direcciones',
'No conduce nunca',
],
correctIndex: 0,
},
},
mathElecChallenge(
'diode-04', 'electronics.diodes',
'Diodos en serie',
'Tres LEDs con caída de 2V cada uno en serie, alimentados por 12V. ¿Qué resistencia necesitas para 10mA?',
600, 3,
['Vtotal_leds = 3 × 2V = 6V. R = (12-6) / 0.01'],
5,
'Cuando pones diodos/LEDs en serie, las caídas de voltaje se SUMAN:\n\nVtotal_leds = n × Vled\nVresistencia = Vfuente - Vtotal_leds\nR = Vresistencia / I\n\nEjemplo: 2 LEDs de 1.8V con 5V y 20mA → R = (5-3.6)/0.02 = 70Ω'
),
];
// =========================================================================
// TRANSISTORS (expanded)
// =========================================================================
export const transistorChallenges: Challenge[] = [
elecChallenge(
'tr-01', 'electronics.transistors',
'NMOS como Interruptor',
'Usa un transistor NMOS para controlar un LED. Cuando el gate tiene voltaje alto (5V), el LED debe encenderse.',
{
type: 'electronics-lab',
availableComponents: ['voltage-source', 'resistor', 'nmos', 'ground', 'voltmeter', 'ammeter'],
probes: [
{ type: 'voltmeter', expected: 0, tolerance: 1, label: 'Voltaje en drain del NMOS (bajo = ON)', unit: 'V' },
],
},
3,
['Conecta: 5V → Resistencia → Drain del NMOS. Source del NMOS → GND. Gate → 5V.'],
'Un transistor MOSFET funciona como un interruptor controlado por voltaje.\n\nNMOS (N-channel MOSFET):\n• Gate = señal de control\n• Drain = entrada de corriente\n• Source = salida (conectar a GND)\n\nCuando Vgate > umbral (~2V) → ENCIENDE (conduce)\nCuando Vgate < umbral → APAGA\n\nCircuito:\n 5V → [R] → Drain ── Source → GND\n ↑\n Gate = 5V (ON) o 0V (OFF)'
),
elecChallenge(
'tr-02', 'electronics.transistors',
'PMOS como Interruptor',
'El PMOS se enciende cuando el gate tiene voltaje BAJO. Conecta un PMOS para que conduzca.',
{
type: 'electronics-lab',
availableComponents: ['voltage-source', 'resistor', 'pmos', 'ground', 'voltmeter'],
probes: [
{ type: 'voltmeter', expected: 0, tolerance: 1, label: 'Voltaje en drain del PMOS', unit: 'V' },
],
},
3,
['PMOS: Source a VDD (5V), Gate a GND (0V), Drain a la carga+GND.'],
'PMOS (P-channel MOSFET) — complementario del NMOS:\n\n• Se ENCIENDE cuando Vgate < Vsource - umbral\n• Se APAGA cuando gate está alto\n\n| | NMOS | PMOS |\n| Gate alto | ON | OFF |\n| Gate bajo | OFF | ON |\n\nEl PMOS conecta Source a VDD y Drain a la carga.\nJuntos forman la tecnología CMOS.'
),
elecChallenge(
'tr-03', 'electronics.transistors',
'Inversor CMOS (NOT)',
'Construye un inversor (NOT) con un PMOS y un NMOS. Entrada alta (5V) → salida baja (0V).',
{
type: 'electronics-lab',
availableComponents: ['voltage-source', 'nmos', 'pmos', 'ground', 'voltmeter'],
probes: [
{ type: 'voltmeter', expected: 0, tolerance: 1, label: 'Voltaje de salida (debe ser ~0V con entrada alta)', unit: 'V' },
],
},
4,
['PMOS: Source→VDD, Gate→entrada. NMOS: Source→GND, Gate→entrada. Drains juntos = salida.'],
'El inversor CMOS es el circuito fundamental de TODA la electrónica digital moderna.\n\nEstructura:\n VDD (5V)\n |\n [PMOS] Source→VDD, Gate→Entrada\n |\n Salida ← Drains conectados\n |\n [NMOS] Source→GND, Gate→Entrada\n |\n GND\n\nEntrada HIGH → NMOS ON, PMOS OFF → Salida = GND = LOW\nEntrada LOW → NMOS OFF, PMOS ON → Salida = VDD = HIGH\n\n¡Esto es una puerta NOT hecha con transistores!'
),
{
id: 'electronics.transistors/tr-04',
nodeId: 'electronics.transistors',
title: 'CMOS vs NMOS puro',
description: '¿Por qué CMOS (PMOS + NMOS) es mejor que usar solo NMOS con resistencia de pull-up?',
difficulty: 3,
type: 'multiple-choice',
hints: ['Piensa en el consumo de energía cuando el circuito está en reposo'],
xpReward: 75,
explanation: 'Con NMOS + resistencia de pull-up:\n• Cuando NMOS ON: corriente fluye de VDD → R → NMOS → GND\n• Consume energía CONSTANTEMENTE en estado LOW\n\nCon CMOS (PMOS + NMOS):\n• Solo uno conduce a la vez\n• Nunca hay camino directo de VDD a GND\n• Consumo casi CERO en reposo\n\nPor eso todos los chips modernos usan CMOS — millones de transistores sin derretirse.',
content: {
type: 'multiple-choice',
question: '¿Cuál es la principal ventaja de CMOS sobre NMOS puro?',
options: [
'Es más rápido',
'Consume mucha menos energía en reposo',
'Usa menos transistores',
'Es más fácil de fabricar',
],
correctIndex: 1,
},
},
];
// =========================================================================
// OP-AMP
// =========================================================================
export const opampChallenges: Challenge[] = [
{
id: 'electronics.opamp/opamp-01',
nodeId: 'electronics.opamp',
title: 'El Op-Amp ideal',
description: '¿Cuáles son las dos reglas doradas del amplificador operacional ideal?',
difficulty: 3,
type: 'multiple-choice',
hints: ['Piensa en corriente de entrada e igualdad de voltajes'],
xpReward: 75,
explanation: 'El amplificador operacional (op-amp) es un bloque analógico versátil.\n\nTiene 3 terminales: entrada no-inversora (+), entrada inversora (-), y salida.\n\nReglas doradas del op-amp ideal (con realimentación negativa):\n\n1. No entra corriente por las entradas (impedancia infinita)\n2. El voltaje en (+) es IGUAL al voltaje en (-)\n\nCon estas dos reglas puedes analizar CUALQUIER circuito con op-amps.\n\nEl op-amp multiplica la diferencia de voltaje entre sus entradas por una ganancia muy alta (100,000x o más).',
content: {
type: 'multiple-choice',
question: '¿Cuáles son las reglas doradas del op-amp ideal (con feedback negativo)?',
options: [
'Ganancia infinita y salida siempre 0V',
'No entra corriente por las entradas Y V(+) = V(-)',
'Consume 0W y tiene delay 0',
'Solo amplifica señales DC',
],
correctIndex: 1,
},
},
mathElecChallenge(
'opamp-02', 'electronics.opamp',
'Amplificador Inversor',
'Un op-amp inversor tiene Rf=10kΩ y Rin=2kΩ. Si la entrada es 0.5V, ¿cuál es el voltaje de salida? (con signo)',
-2.5, 3,
['Ganancia = -Rf/Rin = -10000/2000 = -5. Vout = Ganancia × Vin'],
0.1,
'El amplificador inversor:\n\nVin → [Rin] → nodo (-) del op-amp\n |\n [Rf] → Salida\n\nGanancia = -Rf / Rin\n\nEjemplo: con Rf=20kΩ, Rin=5kΩ → G = -20000/5000 = -4\nSi Vin=1V → Vout = -4 × 1 = -4V\n\nEl signo negativo indica que INVIERTE la señal.'
),
mathElecChallenge(
'opamp-03', 'electronics.opamp',
'Amplificador No Inversor',
'Un op-amp no inversor tiene Rf=9kΩ y R1=1kΩ. Si la entrada es 1V, ¿cuál es el voltaje de salida?',
10, 3,
['Ganancia = 1 + Rf/R1 = 1 + 9 = 10. Vout = 10 × 1V'],
0.1,
'El amplificador no inversor:\n\nVin → entrada (+) del op-amp\n entrada (-) → [R1] → GND\n |\n [Rf] → Salida\n\nGanancia = 1 + Rf/R1\n\nEjemplo: Rf=15kΩ, R1=5kΩ → G = 1 + 15000/5000 = 4\nSi Vin=2V → Vout = 4 × 2 = 8V\n\nNo invierte la señal. La ganancia siempre es ≥ 1.'
),
];
// =========================================================================
// POWER SUPPLIES
// =========================================================================
export const powerChallenges: Challenge[] = [
{
id: 'electronics.power/pwr-01',
nodeId: 'electronics.power',
title: 'De AC a DC',
description: '¿Cuál es el orden correcto para convertir corriente alterna (AC) en corriente continua (DC) estable?',
difficulty: 3,
type: 'multiple-choice',
hints: ['Piensa en: transformar → rectificar → suavizar → regular'],
xpReward: 75,
explanation: 'Las fuentes de alimentación convierten AC (de la red, 230V~) a DC estable:\n\n1. Transformador: baja el voltaje (230V → 12V)\n2. Rectificador: convierte AC a DC pulsante (usando diodos)\n3. Filtro: suaviza el rizado con condensadores\n4. Regulador: estabiliza el voltaje final (ej: 5V exactos)\n\nCada etapa resuelve un problema:\n• Transformador → voltaje demasiado alto\n• Rectificador → corriente alterna\n• Filtro → picos y valles\n• Regulador → variaciones residuales',
content: {
type: 'multiple-choice',
question: '¿Cuál es el orden correcto para una fuente DC?',
options: [
'Regulador → Rectificador → Filtro → Transformador',
'Transformador → Rectificador → Filtro → Regulador',
'Rectificador → Transformador → Regulador → Filtro',
'Filtro → Regulador → Transformador → Rectificador',
],
correctIndex: 1,
},
},
mathElecChallenge(
'pwr-02', 'electronics.power',
'Potencia disipada',
'Un regulador de voltaje convierte 12V a 5V con una corriente de 500mA. ¿Cuántos watts disipa el regulador en calor?',
3.5, 3,
['P = (Vin - Vout) × I = (12-5) × 0.5'],
0.1,
'Un regulador lineal "quema" el exceso de voltaje como calor:\n\nP = (Vin - Vout) × I\n\nEjemplo: 9V→3.3V a 200mA → P = (9-3.3) × 0.2 = 1.14W\n\nPor eso los reguladores llevan disipador.\nLos reguladores switching son más eficientes (>90%) pero más complejos.'
),
mathElecChallenge(
'pwr-03', 'electronics.power',
'Condensador de filtrado',
'Después del rectificador, necesitas un condensador para suavizar 5V con rizado <0.1V a 100mA y 50Hz. ¿Qué capacitancia mínima en µF? (redondeada)',
20000, 3,
['C = I / (f × Vrizado) = 0.1 / (50 × 0.1). El resultado es en Faradios, convierte a µF.'],
1000,
'El condensador de filtrado suaviza el voltaje pulsante del rectificador:\n\nC = I / (f × Vrizado)\n\nEjemplo: con 50mA, 60Hz y Vrizado=0.2V → C = 0.05/(60×0.2) = 4167µF\n\nCuanto menos rizado quieras, más capacitancia necesitas. Son condensadores grandes (electrolíticos).'
),
];
// =========================================================================
// EXPORT ALL
// =========================================================================
export const allElectronicsChallenges: Challenge[] = [
...electricityBasicsChallenges,
...seriesParallelChallenges,
...capacitorChallenges,
...diodeChallenges,
...transistorChallenges,
...opampChallenges,
...powerChallenges,
];

View File

@@ -32,76 +32,76 @@ function mathChallenge(
export const arithmeticChallenges: Challenge[] = [ export const arithmeticChallenges: Challenge[] = [
// Addition // Addition
mathChallenge('add-01', 'arithmetic.addition', 'Suma simple', '¿Cuánto es 7 + 5?', 12, 1, ['Cuenta desde 7 hacia arriba'], 0, mathChallenge('add-01', 'arithmetic.addition', 'Suma simple', '¿Cuánto es 7 + 5?', 12, 1, ['Cuenta desde 7 hacia arriba'], 0,
'La suma es la operación más básica. Combina dos cantidades en una sola.\n\nPor ejemplo: si tienes 3 manzanas y te dan 2 más, ahora tienes 3 + 2 = 5 manzanas.\n\nTruco: para sumar mentalmente, empieza por el número más grande y cuenta hacia arriba. Por ejemplo, para 7 + 5: empieza en 7 y cuenta 5 más → 8, 9, 10, 11, 12.'), 'La suma es la operación más básica. Combina dos cantidades en una sola.\n\nPor ejemplo: si tienes 3 manzanas y te dan 2 más, ahora tienes 3 + 2 = 5 manzanas.\n\nTruco: para sumar mentalmente, empieza por el número más grande y cuenta hacia arriba. Por ejemplo, para 8 + 6: empieza en 8 y cuenta 6 más → 9, 10, 11, 12, 13, 14.'),
mathChallenge('add-02', 'arithmetic.addition', 'Suma de dos cifras', '¿Cuánto es 34 + 27?', 61, 1, ['Suma las unidades primero: 4+7=11']), mathChallenge('add-02', 'arithmetic.addition', 'Suma de dos cifras', '¿Cuánto es 34 + 27?', 61, 1, ['Suma las unidades primero: 4+7=11']),
mathChallenge('add-03', 'arithmetic.addition', 'Suma de tres números', '¿Cuánto es 15 + 23 + 42?', 80, 1, ['Suma de dos en dos']), mathChallenge('add-03', 'arithmetic.addition', 'Suma de tres números', '¿Cuánto es 15 + 23 + 42?', 80, 1, ['Suma de dos en dos']),
mathChallenge('add-04', 'arithmetic.addition', 'Suma con centenas', '¿Cuánto es 256 + 189?', 445, 1, ['Empieza por las unidades: 6+9=15']), mathChallenge('add-04', 'arithmetic.addition', 'Suma con centenas', '¿Cuánto es 256 + 189?', 445, 1, ['Empieza por las unidades: 6+9=15']),
// Subtraction // Subtraction
mathChallenge('sub-01', 'arithmetic.subtraction', 'Resta simple', '¿Cuánto es 15 - 8?', 7, 1, ['Cuenta hacia atrás desde 15'], 0, mathChallenge('sub-01', 'arithmetic.subtraction', 'Resta simple', '¿Cuánto es 15 - 8?', 7, 1, ['Cuenta hacia atrás desde 15'], 0,
'La resta es la operación inversa de la suma. Quita una cantidad de otra.\n\nPor ejemplo: si tienes 10 galletas y comes 3, te quedan 10 - 3 = 7.\n\nTruco: puedes pensar "¿qué le sumo a 8 para llegar a 15?". Si 8 + 7 = 15, entonces 15 - 8 = 7.'), 'La resta es la operación inversa de la suma. Quita una cantidad de otra.\n\nPor ejemplo: si tienes 13 galletas y comes 5, te quedan 13 - 5 = 8.\n\nTruco: puedes pensar "¿qué le sumo a 5 para llegar a 13?". Si 5 + 8 = 13, entonces 13 - 5 = 8.'),
mathChallenge('sub-02', 'arithmetic.subtraction', 'Resta de dos cifras', '¿Cuánto es 82 - 47?', 35, 1, ['Necesitas "pedir prestado" en las unidades']), mathChallenge('sub-02', 'arithmetic.subtraction', 'Resta de dos cifras', '¿Cuánto es 82 - 47?', 35, 1, ['Necesitas "pedir prestado" en las unidades']),
mathChallenge('sub-03', 'arithmetic.subtraction', 'Resta con centenas', '¿Cuánto es 500 - 237?', 263, 1), mathChallenge('sub-03', 'arithmetic.subtraction', 'Resta con centenas', '¿Cuánto es 500 - 237?', 263, 1),
// Multiplication // Multiplication
mathChallenge('mul-01', 'arithmetic.multiplication', 'Multiplicación básica', '¿Cuánto es 6 × 7?', 42, 1, ['Piensa en 6 grupos de 7'], 0, mathChallenge('mul-01', 'arithmetic.multiplication', 'Multiplicación básica', '¿Cuánto es 6 × 7?', 42, 1, ['Piensa en 6 grupos de 7'], 0,
'La multiplicación es una suma repetida. 6 × 7 significa "sumar 7 seis veces" (o "sumar 6 siete veces").\n\n6 × 7 = 7 + 7 + 7 + 7 + 7 + 7 = 42\n\nAprender las tablas de multiplicar de memoria es muy útil. Truco: si no recuerdas 6×7, piensa en 6×5=30 y luego suma 6×2=12 → 30+12=42.'), 'La multiplicación es una suma repetida. 4 × 8 significa "sumar 8 cuatro veces" (o "sumar 4 ocho veces").\n\n4 × 8 = 8 + 8 + 8 + 8 = 32\n\nAprender las tablas de multiplicar de memoria es muy útil. Truco: si no recuerdas 4×8, piensa en 4×5=20 y luego suma 4×3=12 → 20+12=32.'),
mathChallenge('mul-02', 'arithmetic.multiplication', 'Multiplicación de dos cifras', '¿Cuánto es 12 × 15?', 180, 1, ['12 × 15 = 12 × 10 + 12 × 5']), mathChallenge('mul-02', 'arithmetic.multiplication', 'Multiplicación de dos cifras', '¿Cuánto es 12 × 15?', 180, 1, ['12 × 15 = 12 × 10 + 12 × 5']),
mathChallenge('mul-03', 'arithmetic.multiplication', 'Multiplicación avanzada', '¿Cuánto es 25 × 32?', 800, 2, ['25 × 32 = 25 × 4 × 8']), mathChallenge('mul-03', 'arithmetic.multiplication', 'Multiplicación avanzada', '¿Cuánto es 25 × 32?', 800, 2, ['25 × 32 = 25 × 4 × 8']),
// Division // Division
mathChallenge('div-01', 'arithmetic.division', 'División exacta', '¿Cuánto es 56 ÷ 8?', 7, 1, ['¿Qué número × 8 = 56?'], 0, mathChallenge('div-01', 'arithmetic.division', 'División exacta', '¿Cuánto es 56 ÷ 8?', 7, 1, ['¿Qué número × 8 = 56?'], 0,
'La división es la operación inversa de la multiplicación. Reparte una cantidad en partes iguales.\n\n56 ÷ 8 significa: "¿en cuántos grupos de 8 cabe 56?" o "si reparto 56 entre 8, ¿cuánto toca a cada uno?"\n\nTruco: piensa "¿qué número multiplicado por 8 da 56?". Como 7 × 8 = 56, entonces 56 ÷ 8 = 7.'), 'La división es la operación inversa de la multiplicación. Reparte una cantidad en partes iguales.\n\n36 ÷ 6 significa: "¿en cuántos grupos de 6 cabe 36?" o "si reparto 36 entre 6, ¿cuánto toca a cada uno?"\n\nTruco: piensa "¿qué número multiplicado por 6 da 36?". Como 6 × 6 = 36, entonces 36 ÷ 6 = 6.'),
mathChallenge('div-02', 'arithmetic.division', 'División de dos cifras', '¿Cuánto es 144 ÷ 12?', 12, 1), mathChallenge('div-02', 'arithmetic.division', 'División de dos cifras', '¿Cuánto es 144 ÷ 12?', 12, 1),
mathChallenge('div-03', 'arithmetic.division', 'División con decimales', '¿Cuánto es 7 ÷ 4?', 1.75, 2, ['Divide y continúa con decimales'], 0.01), mathChallenge('div-03', 'arithmetic.division', 'División con decimales', '¿Cuánto es 7 ÷ 4?', 1.75, 2, ['Divide y continúa con decimales'], 0.01),
]; ];
export const fractionChallenges: Challenge[] = [ export const fractionChallenges: Challenge[] = [
mathChallenge('frac-01', 'arithmetic.fractions', 'Suma de fracciones', '¿Cuánto es 1/3 + 1/6?', 0.5, 2, ['Encuentra un denominador común: 6'], 0.01, mathChallenge('frac-01', 'arithmetic.fractions', 'Suma de fracciones', '¿Cuánto es 1/3 + 1/6?', 0.5, 2, ['Encuentra un denominador común: 6'], 0.01,
'Las fracciones representan partes de un todo. 1/3 = "una de tres partes", 1/6 = "una de seis partes".\n\nPara sumar fracciones necesitas el mismo denominador (la parte de abajo):\n• 1/3 = 2/6 (multiplica arriba y abajo por 2)\n• 2/6 + 1/6 = 3/6 = 1/2 = 0.5\n\nRegla: busca el mínimo común denominador, convierte ambas fracciones, y luego suma los numeradores.'), 'Las fracciones representan partes de un todo. 1/3 = "una de tres partes", 1/6 = "una de seis partes".\n\nPara sumar fracciones necesitas el mismo denominador (la parte de abajo):\n• 1/4 = 1/4\n• 1/2 = 2/4 (multiplica arriba y abajo por 2)\n• 1/4 + 2/4 = 3/4 = 0.75\n\nRegla: busca el mínimo común denominador, convierte ambas fracciones, y luego suma los numeradores.'),
mathChallenge('frac-02', 'arithmetic.fractions', 'Multiplicación de fracciones', '¿Cuánto es 2/3 × 3/4?', 0.5, 2, ['Multiplica numerador con numerador y denominador con denominador'], 0.01), mathChallenge('frac-02', 'arithmetic.fractions', 'Multiplicación de fracciones', '¿Cuánto es 2/3 × 3/4?', 0.5, 2, ['Multiplica numerador con numerador y denominador con denominador'], 0.01),
mathChallenge('frac-03', 'arithmetic.fractions', 'Fracción a decimal', '¿Cuánto es 5/8 en decimal?', 0.625, 2, ['Divide 5 entre 8'], 0.001), mathChallenge('frac-03', 'arithmetic.fractions', 'Fracción a decimal', '¿Cuánto es 5/8 en decimal?', 0.625, 2, ['Divide 5 entre 8'], 0.001),
]; ];
export const decimalChallenges: Challenge[] = [ export const decimalChallenges: Challenge[] = [
mathChallenge('dec-01', 'arithmetic.decimals', 'Suma de decimales', '¿Cuánto es 3.7 + 2.85?', 6.55, 2, ['Alinea los puntos decimales'], 0.01, mathChallenge('dec-01', 'arithmetic.decimals', 'Suma de decimales', '¿Cuánto es 3.7 + 2.85?', 6.55, 2, ['Alinea los puntos decimales'], 0.01,
'Los decimales son otra forma de escribir fracciones. 3.7 = 3 + 7/10, y 2.85 = 2 + 85/100.\n\nPara sumar decimales, alinea los puntos decimales y suma columna por columna:\n 3.70\n+ 2.85\n------\n 6.55\n\nTruco: si un número tiene menos decimales, añade ceros al final (3.7 → 3.70) para que sea más fácil alinearlos.'), 'Los decimales son otra forma de escribir fracciones. 3.7 = 3 + 7/10, y 2.85 = 2 + 85/100.\n\nPara sumar decimales, alinea los puntos decimales y suma columna por columna:\n 2.30\n+ 1.45\n------\n 3.75\n\nTruco: si un número tiene menos decimales, añade ceros al final (2.3 → 2.30) para que sea más fácil alinearlos.'),
mathChallenge('dec-02', 'arithmetic.decimals', 'Redondeo', 'Redondea 3.746 a dos decimales', 3.75, 2, ['Mira el tercer decimal: 6 ≥ 5, sube'], 0.001), mathChallenge('dec-02', 'arithmetic.decimals', 'Redondeo', 'Redondea 3.746 a dos decimales', 3.75, 2, ['Mira el tercer decimal: 6 ≥ 5, sube'], 0.001),
mathChallenge('dec-03', 'arithmetic.decimals', 'Multiplicación decimal', '¿Cuánto es 2.5 × 0.4?', 1, 2, ['2.5 × 0.4 = 25 × 4 ÷ 100']), mathChallenge('dec-03', 'arithmetic.decimals', 'Multiplicación decimal', '¿Cuánto es 2.5 × 0.4?', 1, 2, ['2.5 × 0.4 = 25 × 4 ÷ 100']),
]; ];
export const percentageChallenges: Challenge[] = [ export const percentageChallenges: Challenge[] = [
mathChallenge('pct-01', 'arithmetic.percentages', 'Porcentaje básico', '¿Cuánto es el 25% de 200?', 50, 2, ['25% = 1/4'], 0, mathChallenge('pct-01', 'arithmetic.percentages', 'Porcentaje básico', '¿Cuánto es el 25% de 200?', 50, 2, ['25% = 1/4'], 0,
'Porcentaje significa "por cada cien". 25% = 25/100 = 0.25\n\nPara calcular un porcentaje de un número, multiplica el número por el porcentaje en decimal:\n• 25% de 200 = 0.25 × 200 = 50\n\nAtajos útiles:\n• 50% = la mitad\n• 25% = un cuarto\n• 10% = mover el punto decimal una posición a la izquierda\n• 1% = mover el punto decimal dos posiciones'), 'Porcentaje significa "por cada cien". 25% = 25/100 = 0.25\n\nPara calcular un porcentaje de un número, multiplica el número por el porcentaje en decimal:\n• 20% de 150 = 0.20 × 150 = 30\n\nAtajos útiles:\n• 50% = la mitad\n• 25% = un cuarto\n• 10% = mover el punto decimal una posición a la izquierda\n• 1% = mover el punto decimal dos posiciones'),
mathChallenge('pct-02', 'arithmetic.percentages', 'Descuento', 'Un artículo cuesta 80€. Con 15% de descuento, ¿cuánto pagas?', 68, 2, ['Calcula el 15% de 80 y réstalo']), mathChallenge('pct-02', 'arithmetic.percentages', 'Descuento', 'Un artículo cuesta 80€. Con 15% de descuento, ¿cuánto pagas?', 68, 2, ['Calcula el 15% de 80 y réstalo']),
mathChallenge('pct-03', 'arithmetic.percentages', 'Porcentaje inverso', 'Si 30 es el 60% de un número, ¿cuál es ese número?', 50, 2, ['30 = 0.6 × x, entonces x = 30/0.6']), mathChallenge('pct-03', 'arithmetic.percentages', 'Porcentaje inverso', 'Si 30 es el 60% de un número, ¿cuál es ese número?', 50, 2, ['30 = 0.6 × x, entonces x = 30/0.6']),
]; ];
export const primeChallenges: Challenge[] = [ export const primeChallenges: Challenge[] = [
mathChallenge('prime-01', 'number-theory.primes', '¿Es primo?', '¿Cuántos números primos hay entre 1 y 20?', 8, 2, ['Los primos son: 2, 3, 5, 7, 11, 13, 17, 19'], 0, mathChallenge('prime-01', 'number-theory.primes', '¿Es primo?', '¿Cuántos números primos hay entre 1 y 20?', 8, 2, ['Los primos son: 2, 3, 5, 7, 11, 13, 17, 19'], 0,
'Un número primo es un número mayor que 1 que solo es divisible por 1 y por sí mismo.\n\nEjemplos:\n• 2 es primo (solo divisible por 1 y 2)\n• 4 NO es primo (divisible por 1, 2 y 4)\n• 7 es primo\n• 9 NO es primo (3 × 3 = 9)\n\nEl 1 no se considera primo. El 2 es el único primo par.\n\nPara verificar si un número es primo, comprueba si es divisible por algún número hasta su raíz cuadrada.'), 'Un número primo es un número mayor que 1 que solo es divisible por 1 y por sí mismo.\n\nEntre 1 y 10, los primos son: 2, 3, 5, 7 (hay 4).\n\n• 2 es primo (solo divisible por 1 y 2)\n• 4 NO es primo (divisible por 1, 2 y 4)\n• 5 es primo\n• 9 NO es primo (3 × 3 = 9)\n\nEl 1 no se considera primo. El 2 es el único primo par.\n\nPara verificar si un número es primo, comprueba si es divisible por algún número hasta su raíz cuadrada.'),
mathChallenge('prime-02', 'number-theory.primes', 'Factorización', '¿Cuál es el factor primo más grande de 84?', 7, 2, ['84 = 2 × 42 = 2 × 2 × 21 = 2 × 2 × 3 × 7']), mathChallenge('prime-02', 'number-theory.primes', 'Factorización', '¿Cuál es el factor primo más grande de 84?', 7, 2, ['84 = 2 × 42 = 2 × 2 × 21 = 2 × 2 × 3 × 7']),
mathChallenge('prime-03', 'number-theory.primes', 'Primo siguiente', '¿Cuál es el siguiente número primo después de 23?', 29, 2, ['Comprueba 24, 25, 26, 27, 28, 29...']), mathChallenge('prime-03', 'number-theory.primes', 'Primo siguiente', '¿Cuál es el siguiente número primo después de 23?', 29, 2, ['Comprueba 24, 25, 26, 27, 28, 29...']),
]; ];
export const gcdLcmChallenges: Challenge[] = [ export const gcdLcmChallenges: Challenge[] = [
mathChallenge('gcd-01', 'number-theory.gcd-lcm', 'MCD básico', '¿Cuál es el MCD de 24 y 36?', 12, 2, ['Factoriza: 24=2³×3, 36=2²×3²'], 0, mathChallenge('gcd-01', 'number-theory.gcd-lcm', 'MCD básico', '¿Cuál es el MCD de 24 y 36?', 12, 2, ['Factoriza: 24=2³×3, 36=2²×3²'], 0,
'El MCD (Máximo Común Divisor) es el número más grande que divide a dos números exactamente.\n\nMétodo de factorización:\n1. Descompón cada número en factores primos:\n • 24 = 2 × 2 × 2 × 3 = 2³ × 3\n • 36 = 2 × 2 × 3 × 3 = 2² × 3²\n2. Toma los factores comunes con el menor exponente:\n • Factor 2: mín(3,2) = 2² = 4\n • Factor 3: mín(1,2) = 3¹ = 3\n3. Multiplica: 4 × 3 = 12\n\nEl MCD de 24 y 36 es 12.\n\nMétodo alternativo (Euclides): divide el mayor entre el menor, luego el menor entre el resto, repite hasta que el resto sea 0. El último divisor es el MCD.\n• 36 ÷ 24 = 1 resto 12\n• 24 ÷ 12 = 2 resto 0 → MCD = 12'), 'El MCD (Máximo Común Divisor) es el número más grande que divide a dos números exactamente.\n\nMétodo de factorización:\n1. Descompón cada número en factores primos:\n • 18 = 2 × 3 × 3 = 2 × 3²\n • 12 = 2 × 2 × 3 = 2² × 3\n2. Toma los factores comunes con el menor exponente:\n • Factor 2: mín(1,2) = 2¹ = 2\n • Factor 3: mín(2,1) = 3¹ = 3\n3. Multiplica: 2 × 3 = 6\n\nEl MCD de 18 y 12 es 6.\n\nMétodo alternativo (Euclides): divide el mayor entre el menor, luego el menor entre el resto, repite hasta que el resto sea 0. El último divisor es el MCD.\n• 18 ÷ 12 = 1 resto 6\n• 12 ÷ 6 = 2 resto 0 → MCD = 6'),
mathChallenge('gcd-02', 'number-theory.gcd-lcm', 'MCM básico', '¿Cuál es el MCM de 6 y 8?', 24, 2, ['MCM = (6×8)/MCD(6,8)'], 0, mathChallenge('gcd-02', 'number-theory.gcd-lcm', 'MCM básico', '¿Cuál es el MCM de 6 y 8?', 24, 2, ['MCM = (6×8)/MCD(6,8)'], 0,
'El MCM (Mínimo Común Múltiplo) es el número más pequeño que es múltiplo de ambos números.\n\n¡No confundir con el MCD!\n• MCD = el mayor número que DIVIDE a ambos (busca divisores)\n• MCM = el menor número que es MÚLTIPLO de ambos (busca múltiplos)\n\nMétodo 1 — listar múltiplos:\n• Múltiplos de 6: 6, 12, 18, 24, 30...\n• Múltiplos de 8: 8, 16, 24, 32...\n• El primero en común es 24\n\nMétodo 2 — fórmula rápida:\nMCM(a,b) = (a × b) / MCD(a,b)\nMCM(6,8) = (6 × 8) / MCD(6,8) = 48 / 2 = 24\n\nEl MCM se usa mucho para sumar fracciones con distinto denominador.'), 'El MCM (Mínimo Común Múltiplo) es el número más pequeño que es múltiplo de ambos números.\n\n¡No confundir con el MCD!\n• MCD = el mayor número que DIVIDE a ambos (busca divisores)\n• MCM = el menor número que es MÚLTIPLO de ambos (busca múltiplos)\n\nMétodo 1 — listar múltiplos:\n• Múltiplos de 4: 4, 8, 12, 16, 20...\n• Múltiplos de 6: 6, 12, 18, 24...\n• El primero en común es 12\n\nMétodo 2 — fórmula rápida:\nMCM(a,b) = (a × b) / MCD(a,b)\nMCM(4,6) = (4 × 6) / MCD(4,6) = 24 / 2 = 12\n\nEl MCM se usa mucho para sumar fracciones con distinto denominador.'),
mathChallenge('gcd-03', 'number-theory.gcd-lcm', 'MCD de tres números', '¿Cuál es el MCD de 12, 18 y 30?', 6, 2), mathChallenge('gcd-03', 'number-theory.gcd-lcm', 'MCD de tres números', '¿Cuál es el MCD de 12, 18 y 30?', 6, 2),
]; ];
export const variableChallenges: Challenge[] = [ export const variableChallenges: Challenge[] = [
mathChallenge('var-01', 'algebra.variables', 'Evaluar expresión', 'Si x = 3, ¿cuánto vale 2x + 5?', 11, 2, ['Sustituye x por 3: 2(3) + 5'], 0, mathChallenge('var-01', 'algebra.variables', 'Evaluar expresión', 'Si x = 3, ¿cuánto vale 2x + 5?', 11, 2, ['Sustituye x por 3: 2(3) + 5'], 0,
'En álgebra usamos letras (variables) para representar números desconocidos.\n\n"2x" significa "2 multiplicado por x". Si x = 3:\n• 2x = 2 × 3 = 6\n• 2x + 5 = 6 + 5 = 11\n\nEvaluar una expresión es sustituir la variable por su valor y calcular el resultado. Siempre resuelve multiplicaciones antes que sumas (orden de operaciones).'), 'En álgebra usamos letras (variables) para representar números desconocidos.\n\n"3x" significa "3 multiplicado por x". Si x = 4:\n• 3x = 3 × 4 = 12\n• 3x + 2 = 12 + 2 = 14\n\nEvaluar una expresión es sustituir la variable por su valor y calcular el resultado. Siempre resuelve multiplicaciones antes que sumas (orden de operaciones).'),
mathChallenge('var-02', 'algebra.variables', 'Expresión con dos variables', 'Si a = 4 y b = 7, ¿cuánto vale 3a - b + 2?', 7, 2, ['3(4) - 7 + 2 = 12 - 7 + 2']), mathChallenge('var-02', 'algebra.variables', 'Expresión con dos variables', 'Si a = 4 y b = 7, ¿cuánto vale 3a - b + 2?', 7, 2, ['3(4) - 7 + 2 = 12 - 7 + 2']),
mathChallenge('var-03', 'algebra.variables', 'Simplificar', 'Simplifica: 3x + 2x - x. ¿Cuántos "x" quedan?', 4, 2, ['Suma los coeficientes: 3+2-1']), mathChallenge('var-03', 'algebra.variables', 'Simplificar', 'Simplifica: 3x + 2x - x. ¿Cuántos "x" quedan?', 4, 2, ['Suma los coeficientes: 3+2-1']),
]; ];
export const equationChallenges: Challenge[] = [ export const equationChallenges: Challenge[] = [
mathChallenge('eq-01', 'algebra.equations', 'Ecuación simple', 'Resuelve: x + 7 = 15', 8, 3, ['Resta 7 de ambos lados'], 0, mathChallenge('eq-01', 'algebra.equations', 'Ecuación simple', 'Resuelve: x + 7 = 15', 8, 3, ['Resta 7 de ambos lados'], 0,
'Una ecuación es una igualdad con una incógnita (x). Resolverla es encontrar el valor de x.\n\nRegla de oro: lo que hagas a un lado, hazlo al otro.\n\nEjemplo: x + 7 = 15\n• Queremos x sola → restamos 7 de ambos lados\n• x + 7 - 7 = 15 - 7\n• x = 8\n\nComprobación: 8 + 7 = 15 ✓\n\nOperaciones inversas: suma↔resta, multiplicación↔división.'), 'Una ecuación es una igualdad con una incógnita (x). Resolverla es encontrar el valor de x.\n\nRegla de oro: lo que hagas a un lado, hazlo al otro.\n\nEjemplo: x + 5 = 12\n• Queremos x sola → restamos 5 de ambos lados\n• x + 5 - 5 = 12 - 5\n• x = 7\n\nComprobación: 7 + 5 = 12 ✓\n\nOperaciones inversas: suma↔resta, multiplicación↔división.'),
mathChallenge('eq-02', 'algebra.equations', 'Ecuación con multiplicación', 'Resuelve: 3x = 21', 7, 3, ['Divide ambos lados entre 3']), mathChallenge('eq-02', 'algebra.equations', 'Ecuación con multiplicación', 'Resuelve: 3x = 21', 7, 3, ['Divide ambos lados entre 3']),
mathChallenge('eq-03', 'algebra.equations', 'Ecuación de dos pasos', 'Resuelve: 2x + 5 = 17', 6, 3, ['Primero resta 5, luego divide entre 2']), mathChallenge('eq-03', 'algebra.equations', 'Ecuación de dos pasos', 'Resuelve: 2x + 5 = 17', 6, 3, ['Primero resta 5, luego divide entre 2']),
mathChallenge('eq-04', 'algebra.equations', 'Ecuación con paréntesis', 'Resuelve: 3(x - 2) = 15', 7, 3, ['Distribuye: 3x - 6 = 15']), mathChallenge('eq-04', 'algebra.equations', 'Ecuación con paréntesis', 'Resuelve: 3(x - 2) = 15', 7, 3, ['Distribuye: 3x - 6 = 15']),
@@ -109,14 +109,14 @@ export const equationChallenges: Challenge[] = [
export const linearSystemChallenges: Challenge[] = [ export const linearSystemChallenges: Challenge[] = [
mathChallenge('sys-01', 'algebra.linear-systems', 'Sistema simple', 'Resuelve: x + y = 10, x - y = 2. ¿Cuánto vale x?', 6, 3, ['Suma ambas ecuaciones: 2x = 12'], 0, mathChallenge('sys-01', 'algebra.linear-systems', 'Sistema simple', 'Resuelve: x + y = 10, x - y = 2. ¿Cuánto vale x?', 6, 3, ['Suma ambas ecuaciones: 2x = 12'], 0,
'Un sistema de ecuaciones son dos (o más) ecuaciones que deben cumplirse a la vez.\n\nMétodo de eliminación:\n1. Suma o resta las ecuaciones para eliminar una variable\n2. Resuelve la variable que queda\n3. Sustituye para encontrar la otra\n\nEjemplo:\n x + y = 10\n x - y = 2\n\nSumando ambas: (x+y) + (x-y) = 10+2 → 2x = 12 → x = 6\nSustituyendo: 6 + y = 10 → y = 4'), 'Un sistema de ecuaciones son dos (o más) ecuaciones que deben cumplirse a la vez.\n\nMétodo de eliminación:\n1. Suma o resta las ecuaciones para eliminar una variable\n2. Resuelve la variable que queda\n3. Sustituye para encontrar la otra\n\nEjemplo:\n x + y = 7\n x - y = 3\n\nSumando ambas: (x+y) + (x-y) = 7+3 → 2x = 10 → x = 5\nSustituyendo: 5 + y = 7 → y = 2'),
mathChallenge('sys-02', 'algebra.linear-systems', 'Sistema por sustitución', 'Resuelve: y = 2x, x + y = 9. ¿Cuánto vale x?', 3, 3, ['Sustituye y: x + 2x = 9']), mathChallenge('sys-02', 'algebra.linear-systems', 'Sistema por sustitución', 'Resuelve: y = 2x, x + y = 9. ¿Cuánto vale x?', 3, 3, ['Sustituye y: x + 2x = 9']),
mathChallenge('sys-03', 'algebra.linear-systems', 'Sistema avanzado', 'Resuelve: 2x + 3y = 16, x - y = 3. ¿Cuánto vale y?', 2, 3, ['De la segunda: x = y + 3. Sustituye en la primera.']), mathChallenge('sys-03', 'algebra.linear-systems', 'Sistema avanzado', 'Resuelve: 2x + 3y = 16, x - y = 3. ¿Cuánto vale y?', 2, 3, ['De la segunda: x = y + 3. Sustituye en la primera.']),
]; ];
export const quadraticChallenges: Challenge[] = [ export const quadraticChallenges: Challenge[] = [
mathChallenge('quad-01', 'algebra.quadratics', 'Cuadrática simple', 'Resuelve: x² = 25. Da la solución positiva.', 5, 3, ['√25 = 5'], 0, mathChallenge('quad-01', 'algebra.quadratics', 'Cuadrática simple', 'Resuelve: x² = 25. Da la solución positiva.', 5, 3, ['√25 = 5'], 0,
'Una ecuación cuadrática contiene x² (x al cuadrado). La forma general es ax² + bx + c = 0.\n\nEl caso más simple: x² = número\n• Solución: x = ±√número\n• x² = 25 → x = +5 o x = -5 (porque tanto 5×5 como (-5)×(-5) dan 25)\n\nPara ecuaciones más complejas, se usa la fórmula general:\nx = (-b ± √(b²-4ac)) / 2a\n\nO se intenta factorizar: x²-5x+6 = (x-2)(x-3) = 0 → x=2 o x=3'), 'Una ecuación cuadrática contiene x² (x al cuadrado). La forma general es ax² + bx + c = 0.\n\nEl caso más simple: x² = número\n• Solución: x = ±√número\n• x² = 16 → x = +4 o x = -4 (porque tanto 4×4 como (-4)×(-4) dan 16)\n\nPara ecuaciones más complejas, se usa la fórmula general:\nx = (-b ± √(b²-4ac)) / 2a\n\nO se intenta factorizar: x²-5x+6 = (x-2)(x-3) = 0 → x=2 o x=3'),
mathChallenge('quad-02', 'algebra.quadratics', 'Factorización', 'Resuelve: x² - 5x + 6 = 0. Da la solución mayor.', 3, 3, ['Factoriza: (x-2)(x-3) = 0']), mathChallenge('quad-02', 'algebra.quadratics', 'Factorización', 'Resuelve: x² - 5x + 6 = 0. Da la solución mayor.', 3, 3, ['Factoriza: (x-2)(x-3) = 0']),
mathChallenge('quad-03', 'algebra.quadratics', 'Fórmula general', 'Resuelve: x² + 2x - 8 = 0. Da la solución positiva.', 2, 3, ['Usa: x = (-b ± √(b²-4ac)) / 2a']), mathChallenge('quad-03', 'algebra.quadratics', 'Fórmula general', 'Resuelve: x² + 2x - 8 = 0. Da la solución positiva.', 2, 3, ['Usa: x = (-b ± √(b²-4ac)) / 2a']),
]; ];
@@ -175,11 +175,14 @@ export const booleanChallenges: Challenge[] = [
export const binaryChallenges: Challenge[] = [ export const binaryChallenges: Challenge[] = [
mathChallenge('bin-01', 'logic.binary', 'Decimal a binario', '¿Cuánto es 13 en binario? (escribe el número decimal que forman los dígitos binarios, ej: 1101)', 1101, 2, ['13 = 8+4+1 = 1101₂'], 0, mathChallenge('bin-01', 'logic.binary', 'Decimal a binario', '¿Cuánto es 13 en binario? (escribe el número decimal que forman los dígitos binarios, ej: 1101)', 1101, 2, ['13 = 8+4+1 = 1101₂'], 0,
'El sistema binario usa solo dos dígitos: 0 y 1. Cada posición vale el doble que la anterior (de derecha a izquierda):\n\n... 16 8 4 2 1\n\nPara convertir decimal a binario, descompón en potencias de 2:\n• 13 = 8 + 4 + 1\n• 13 = 1×8 + 1×4 + 0×2 + 1×1\n• 13 en binario = 1101\n\nMétodo alternativo: divide entre 2 repetidamente y lee los restos de abajo arriba:\n• 13÷2 = 6 resto 1\n• 6÷2 = 3 resto 0\n• 3÷2 = 1 resto 1\n• 1÷2 = 0 resto 1\n→ 1101'), 'El sistema binario usa solo dos dígitos: 0 y 1. Cada posición vale el doble que la anterior (de derecha a izquierda):\n\n... 16 8 4 2 1\n\nPara convertir decimal a binario, descompón en potencias de 2:\n• 11 = 8 + 2 + 1\n• 11 = 1×8 + 0×4 + 1×2 + 1×1\n• 11 en binario = 1011\n\nMétodo alternativo: divide entre 2 repetidamente y lee los restos de abajo arriba:\n• 11÷2 = 5 resto 1\n• 5÷2 = 2 resto 1\n• 2÷2 = 1 resto 0\n• 1÷2 = 0 resto 1\n→ 1011'),
mathChallenge('bin-02', 'logic.binary', 'Binario a decimal', '¿Cuánto es 10110 en decimal?', 22, 2, ['1×16 + 0×8 + 1×4 + 1×2 + 0×1']), mathChallenge('bin-02', 'logic.binary', 'Binario a decimal', '¿Cuánto es 10110 en decimal?', 22, 2, ['1×16 + 0×8 + 1×4 + 1×2 + 0×1']),
mathChallenge('bin-03', 'logic.binary', 'Suma binaria', '¿Cuánto es 1010 + 0110 en decimal?', 16, 2, ['1010=10, 0110=6, 10+6=16']), mathChallenge('bin-03', 'logic.binary', 'Suma binaria', '¿Cuánto es 1010 + 0110 en decimal?', 16, 2, ['1010=10, 0110=6, 10+6=16']),
]; ];
import { allCircuitChallenges } from './circuits';
import { allElectronicsChallenges } from './electronics';
export const allChallenges: Challenge[] = [ export const allChallenges: Challenge[] = [
...arithmeticChallenges, ...arithmeticChallenges,
...fractionChallenges, ...fractionChallenges,
@@ -193,6 +196,8 @@ export const allChallenges: Challenge[] = [
...quadraticChallenges, ...quadraticChallenges,
...booleanChallenges, ...booleanChallenges,
...binaryChallenges, ...binaryChallenges,
...allCircuitChallenges,
...allElectronicsChallenges,
]; ];
export function getChallengeById(id: string): Challenge | undefined { export function getChallengeById(id: string): Challenge | undefined {

View File

@@ -136,7 +136,7 @@ export const skillNodes: SkillNode[] = [
description: 'Introduce variables y evalúa expresiones algebraicas', description: 'Introduce variables y evalúa expresiones algebraicas',
icon: '𝑥', icon: '𝑥',
position: { x: CENTER - COL_GAP / 2, y: row(5) }, position: { x: CENTER - COL_GAP / 2, y: row(5) },
prerequisites: ['arithmetic.percentages'], prerequisites: ['arithmetic.division'],
challenges: ['var-01', 'var-02', 'var-03'], challenges: ['var-01', 'var-02', 'var-03'],
difficulty: 2, difficulty: 2,
}, },
@@ -148,7 +148,7 @@ export const skillNodes: SkillNode[] = [
description: 'AND, OR, NOT — las bases del pensamiento lógico', description: 'AND, OR, NOT — las bases del pensamiento lógico',
icon: '🧠', icon: '🧠',
position: { x: CENTER + COL_GAP, y: row(5) }, position: { x: CENTER + COL_GAP, y: row(5) },
prerequisites: ['number-theory.primes'], prerequisites: ['arithmetic.division'],
challenges: ['bool-01', 'bool-02', 'bool-03'], challenges: ['bool-01', 'bool-02', 'bool-03'],
difficulty: 2, difficulty: 2,
}, },
@@ -204,6 +204,812 @@ export const skillNodes: SkillNode[] = [
challenges: ['quad-01', 'quad-02', 'quad-03'], challenges: ['quad-01', 'quad-02', 'quad-03'],
difficulty: 3, difficulty: 3,
}, },
// === ROW 7 (math-physics branch): Electronics Basics ===
// =========================================================================
// ELECTRONICS — from Ohm's law to analog circuits
// =========================================================================
{
id: 'electronics.basics',
discipline: 'electronics',
branch: 'electronics',
title: 'Electricidad Básica',
description: 'Voltaje, corriente, resistencia y la Ley de Ohm',
icon: '🔋',
position: { x: 0, y: 0 },
prerequisites: ['arithmetic.decimals'],
challenges: ['eb-01', 'eb-02', 'eb-03', 'eb-04'],
difficulty: 2,
},
{
id: 'electronics.series-parallel',
discipline: 'electronics',
branch: 'electronics',
title: 'Serie y Paralelo',
description: 'Resistencias en serie y paralelo, divisores de voltaje y corriente',
icon: '🔀',
position: { x: 0, y: 0 },
prerequisites: ['electronics.basics'],
challenges: ['sp-01', 'sp-02', 'sp-03', 'sp-04'],
difficulty: 2,
},
{
id: 'electronics.capacitors',
discipline: 'electronics',
branch: 'electronics',
title: 'Condensadores',
description: 'Almacena energía en campo eléctrico — carga, descarga, filtrado y constante RC',
icon: '⚡',
position: { x: 0, y: 0 },
prerequisites: ['electronics.series-parallel'],
challenges: ['cap-01', 'cap-02', 'cap-03', 'cap-04', 'cap-05'],
difficulty: 3,
},
{
id: 'electronics.diodes',
discipline: 'electronics',
branch: 'electronics',
title: 'Diodos y LEDs',
description: 'Semiconductores básicos — conducción en un solo sentido, rectificación, LEDs',
icon: '💡',
position: { x: 0, y: 0 },
prerequisites: ['electronics.series-parallel'],
challenges: ['diode-01', 'diode-02', 'diode-03', 'diode-04'],
difficulty: 3,
},
{
id: 'electronics.transistors',
discipline: 'electronics',
branch: 'electronics',
title: 'Transistores',
description: 'MOSFET como interruptor — la base de toda la electrónica digital',
icon: '🔌',
position: { x: 0, y: 0 },
prerequisites: ['electronics.diodes', 'logic.boolean'],
challenges: ['tr-01', 'tr-02', 'tr-03', 'tr-04'],
difficulty: 3,
},
{
id: 'electronics.opamp',
discipline: 'electronics',
branch: 'electronics',
title: 'Amplificador Operacional',
description: 'El bloque fundamental analógico — comparador, amplificador, filtro activo',
icon: '📐',
position: { x: 0, y: 0 },
prerequisites: ['electronics.transistors'],
challenges: ['opamp-01', 'opamp-02', 'opamp-03'],
difficulty: 4,
},
{
id: 'electronics.power',
discipline: 'electronics',
branch: 'electronics',
title: 'Fuentes de Alimentación',
description: 'Reguladores, rectificación, filtrado — de la red eléctrica a voltaje DC estable',
icon: '🔌',
position: { x: 0, y: 0 },
prerequisites: ['electronics.capacitors', 'electronics.diodes'],
challenges: ['pwr-01', 'pwr-02', 'pwr-03'],
difficulty: 3,
},
// =========================================================================
// DIGITAL CIRCUITS — gates, combinational (from logic + transistors)
// =========================================================================
{
id: 'electronics.logic-gates',
discipline: 'digital-circuits',
branch: 'digital',
title: 'Puertas Lógicas',
description: 'Construye circuitos con puertas AND, OR, NOT, NAND y XOR',
icon: '⚡',
position: { x: 0, y: 0 },
prerequisites: ['electronics.transistors', 'logic.binary'],
challenges: ['lg-01', 'lg-02', 'lg-03', 'lg-04', 'lg-05', 'lg-06', 'lg-07', 'lg-08'],
difficulty: 3,
},
{
id: 'electronics.combinational',
discipline: 'digital-circuits',
branch: 'digital',
title: 'Circuitos Combinacionales',
description: 'Combina puertas para construir sumadores y multiplexores',
icon: '🔧',
position: { x: 0, y: 0 },
prerequisites: ['electronics.logic-gates'],
challenges: ['cc-01', 'cc-02', 'cc-03', 'cc-04'],
difficulty: 4,
},
// =========================================================================
// SEQUENTIAL CIRCUITS — flip-flops, registers, counters
// =========================================================================
{
id: 'sequential.flipflops',
discipline: 'sequential-circuits',
branch: 'sequential',
title: 'Flip-Flops',
description: 'SR Latch, D Flip-Flop — circuitos que recuerdan un bit',
icon: '🔄',
position: { x: 0, y: 0 },
prerequisites: ['electronics.combinational'],
challenges: [], // placeholder
difficulty: 3,
},
{
id: 'sequential.registers',
discipline: 'sequential-circuits',
branch: 'sequential',
title: 'Registros',
description: 'Almacena múltiples bits — registros de 8 bits y shift registers',
icon: '📦',
position: { x: 0, y: 0 },
prerequisites: ['sequential.flipflops'],
challenges: [],
difficulty: 3,
},
{
id: 'sequential.counters',
discipline: 'sequential-circuits',
branch: 'sequential',
title: 'Contadores y Reloj',
description: 'Contadores binarios, señal de reloj y temporización',
icon: '⏱️',
position: { x: 0, y: 0 },
prerequisites: ['sequential.flipflops'],
challenges: [],
difficulty: 3,
},
// =========================================================================
// COMPUTER ARCHITECTURE — ALU, Memory, Control Unit, CPU
// =========================================================================
{
id: 'cpu.alu',
discipline: 'computer-architecture',
branch: 'cpu',
title: 'ALU',
description: 'Unidad Aritmético-Lógica — suma, resta, AND, OR, XOR en hardware',
icon: '🧮',
position: { x: 0, y: 0 },
prerequisites: ['electronics.combinational', 'sequential.registers'],
challenges: [],
difficulty: 4,
},
{
id: 'cpu.memory',
discipline: 'computer-architecture',
branch: 'cpu',
title: 'Memoria',
description: 'SRAM, ROM y el bus de datos — almacenamiento de programas y datos',
icon: '💾',
position: { x: 0, y: 0 },
prerequisites: ['sequential.registers'],
challenges: [],
difficulty: 4,
},
{
id: 'cpu.control-isa',
discipline: 'computer-architecture',
branch: 'cpu',
title: 'Unidad de Control e ISA',
description: 'Diseña tu set de instrucciones (ISA) y la unidad de control que las decodifica — fetch, decode, execute',
icon: '🎛️',
position: { x: 0, y: 0 },
prerequisites: ['cpu.alu', 'cpu.memory', 'sequential.counters'],
challenges: [],
difficulty: 5,
},
{
id: 'cpu.complete',
discipline: 'computer-architecture',
branch: 'cpu',
title: 'Tu CPU de 8 bits',
description: 'Ensambla ALU + Registros + Memoria + Control. Escribe tu primer programa: se ejecuta en TU hardware.',
icon: '🖥️',
position: { x: 0, y: 0 },
prerequisites: ['cpu.control-isa'],
challenges: [],
difficulty: 5,
},
// =========================================================================
// PROGRAMMING YOUR CPU — stack, subroutines, interrupts
// (you already know ISA/ASM from building the CPU — now go deeper)
// =========================================================================
{
id: 'asm.stack-subroutines',
discipline: 'low-level-programming',
branch: 'asm',
title: 'Pila y Subrutinas',
description: 'PUSH, POP, CALL, RET — añade un stack pointer a tu CPU y haz funciones',
icon: '📚',
position: { x: 0, y: 0 },
prerequisites: ['cpu.complete'],
challenges: [],
difficulty: 4,
},
{
id: 'asm.interrupts',
discipline: 'low-level-programming',
branch: 'asm',
title: 'Interrupciones y I/O',
description: 'Añade interrupciones a tu CPU — el hardware puede pedir atención al software',
icon: '🔔',
position: { x: 0, y: 0 },
prerequisites: ['asm.stack-subroutines'],
challenges: [],
difficulty: 4,
},
// =========================================================================
// SIGNAL PROCESSING — Waves, Trigonometry applied
// =========================================================================
{
id: 'signals.trigonometry',
discipline: 'signal-processing',
branch: 'signals',
title: 'Trigonometría',
description: 'Seno, coseno, tangente — las funciones que describen ondas',
icon: '📐',
position: { x: 0, y: 0 },
prerequisites: ['algebra.quadratics'],
challenges: [],
difficulty: 3,
},
{
id: 'signals.waves',
discipline: 'signal-processing',
branch: 'signals',
title: 'Ondas y Frecuencia',
description: 'Amplitud, frecuencia, fase — anatomía de una señal',
icon: '🌊',
position: { x: 0, y: 0 },
prerequisites: ['signals.trigonometry'],
challenges: [],
difficulty: 3,
},
{
id: 'signals.sampling',
discipline: 'signal-processing',
branch: 'signals',
title: 'Muestreo y DAC/ADC',
description: 'Convierte señales analógicas a digitales y viceversa — Nyquist, aliasing',
icon: '📊',
position: { x: 0, y: 0 },
prerequisites: ['signals.waves', 'logic.binary'],
challenges: [],
difficulty: 3,
},
// =========================================================================
// AUDIO SYNTHESIS — Oscillators, Filters, Synth, Sound Card
// =========================================================================
{
id: 'audio.oscillators',
discipline: 'audio-synthesis',
branch: 'audio',
title: 'Osciladores',
description: 'Sine, square, sawtooth, triangle — genera sonido desde cero',
icon: '🎵',
position: { x: 0, y: 0 },
prerequisites: ['signals.waves'],
challenges: [],
difficulty: 3,
},
{
id: 'audio.filters',
discipline: 'audio-synthesis',
branch: 'audio',
title: 'Filtros y Envolventes',
description: 'Low-pass, high-pass, ADSR — moldea el timbre y la dinámica del sonido',
icon: '🎚️',
position: { x: 0, y: 0 },
prerequisites: ['audio.oscillators'],
challenges: [],
difficulty: 3,
},
{
id: 'audio.synthesizer',
discipline: 'audio-synthesis',
branch: 'audio',
title: 'Sintetizador',
description: 'Combina osciladores, filtros y efectos para crear tu propio instrumento',
icon: '🎹',
position: { x: 0, y: 0 },
prerequisites: ['audio.filters'],
challenges: [],
difficulty: 4,
},
{
id: 'audio.sound-card',
discipline: 'audio-synthesis',
branch: 'audio',
title: 'Tarjeta de Sonido',
description: 'Diseña el periférico de audio para tu CPU — registros, canales y reproducción',
icon: '🔊',
position: { x: 0, y: 0 },
prerequisites: ['audio.synthesizer', 'asm.interrupts', 'signals.sampling'],
challenges: [],
difficulty: 5,
},
// =========================================================================
// GRAPHICS — Pixels, Framebuffer, Sprites, Tiles
// =========================================================================
{
id: 'graphics.pixels',
discipline: 'graphics',
branch: 'graphics',
title: 'Píxeles y Color',
description: 'RGB, paletas de color, resolución — la base de toda imagen digital',
icon: '🎨',
position: { x: 0, y: 0 },
prerequisites: ['logic.binary'],
challenges: [],
difficulty: 2,
},
{
id: 'graphics.framebuffer',
discipline: 'graphics',
branch: 'graphics',
title: 'Framebuffer y VRAM',
description: 'Memoria de vídeo, escaneo de pantalla y cómo un CPU dibuja en pantalla',
icon: '🖼️',
position: { x: 0, y: 0 },
prerequisites: ['graphics.pixels', 'cpu.memory'],
challenges: [],
difficulty: 3,
},
{
id: 'graphics.sprites',
discipline: 'graphics',
branch: 'graphics',
title: 'Sprites',
description: 'Objetos gráficos independientes — definición, posición, animación por frames',
icon: '👾',
position: { x: 0, y: 0 },
prerequisites: ['graphics.framebuffer'],
challenges: [],
difficulty: 3,
},
{
id: 'graphics.tilemaps',
discipline: 'graphics',
branch: 'graphics',
title: 'Tile Maps',
description: 'Mundos grandes con piezas pequeñas — mapas de tiles, scroll y capas',
icon: '🗺️',
position: { x: 0, y: 0 },
prerequisites: ['graphics.sprites'],
challenges: [],
difficulty: 4,
},
{
id: 'graphics.gpu',
discipline: 'graphics',
branch: 'graphics',
title: 'Tarjeta Gráfica (PPU)',
description: 'Diseña tu Picture Processing Unit — el periférico de vídeo de tu consola',
icon: '📺',
position: { x: 0, y: 0 },
prerequisites: ['graphics.tilemaps', 'asm.interrupts'],
challenges: [],
difficulty: 5,
},
// =========================================================================
// GAME DEV — Input, Game Loop, Final Project
// =========================================================================
{
id: 'game.input',
discipline: 'game-dev',
branch: 'game',
title: 'Input y Controlador',
description: 'Lee el mando/teclado desde tu CPU — registros de input, polling y debounce',
icon: '🎮',
position: { x: 0, y: 0 },
prerequisites: ['asm.interrupts'],
challenges: [],
difficulty: 3,
},
{
id: 'game.gameloop',
discipline: 'game-dev',
branch: 'game',
title: 'Game Loop',
description: 'El bucle principal: input → lógica → render. Timing, VBlank y frames',
icon: '🔁',
position: { x: 0, y: 0 },
prerequisites: ['game.input', 'graphics.gpu'],
challenges: [],
difficulty: 4,
},
{
id: 'game.collision',
discipline: 'game-dev',
branch: 'game',
title: 'Colisiones y Física',
description: 'Detección de colisiones AABB, gravedad simple y movimiento de personajes',
icon: '💥',
position: { x: 0, y: 0 },
prerequisites: ['game.gameloop'],
challenges: [],
difficulty: 4,
},
{
id: 'game.sound-integration',
discipline: 'game-dev',
branch: 'game',
title: 'Integración de Audio',
description: 'Música y efectos de sonido en tu juego — canales, triggers y secuencias',
icon: '🎶',
position: { x: 0, y: 0 },
prerequisites: ['game.gameloop', 'audio.sound-card'],
challenges: [],
difficulty: 4,
},
{
id: 'game.final',
discipline: 'game-dev',
branch: 'game',
title: '🏆 Tu Juego',
description: 'Crea tu propio juego estilo NES: gráficos, sonido, input y lógica en TU consola',
icon: '🏆',
position: { x: 0, y: 0 },
prerequisites: ['game.collision', 'game.sound-integration'],
challenges: [],
difficulty: 5,
},
// =========================================================================
// HDL / VERILOG — describe hardware with code
// =========================================================================
{
id: 'hdl.basics',
discipline: 'digital-circuits',
branch: 'hdl',
title: 'Intro a Verilog',
description: 'Describe hardware con código — modules, wires, assign, always blocks',
icon: '📝',
position: { x: 0, y: 0 },
prerequisites: ['cpu.complete'],
challenges: [],
difficulty: 4,
},
{
id: 'hdl.testbench',
discipline: 'digital-circuits',
branch: 'hdl',
title: 'Testbenches y Simulación',
description: 'Verifica tu hardware antes de fabricarlo — escribe tests, simula señales, analiza timing',
icon: '🧪',
position: { x: 0, y: 0 },
prerequisites: ['hdl.basics'],
challenges: [],
difficulty: 4,
},
{
id: 'hdl.cpu-verilog',
discipline: 'digital-circuits',
branch: 'hdl',
title: 'Tu CPU en Verilog',
description: 'Reimplementa tu CPU de 8 bits en Verilog — ahora puedes iterar rápido',
icon: '🔄',
position: { x: 0, y: 0 },
prerequisites: ['hdl.testbench'],
challenges: [],
difficulty: 5,
},
// =========================================================================
// EXTENDED ARCHITECTURE — 16/32 bit, MMU, peripherals (now using Verilog)
// =========================================================================
{
id: 'ext-arch.16bit',
discipline: 'extended-architecture',
branch: 'ext-arch',
title: 'Ampliación a 16 bits',
description: 'Extiende tu CPU en Verilog: bus de 16 bits, más registros, direccionamiento extendido',
icon: '📏',
position: { x: 0, y: 0 },
prerequisites: ['hdl.cpu-verilog'],
challenges: [],
difficulty: 4,
},
{
id: 'ext-arch.32bit',
discipline: 'extended-architecture',
branch: 'ext-arch',
title: 'Arquitectura de 32 bits',
description: 'Bus de 32 bits, pipeline de instrucciones, caché L1 — un procesador "de verdad"',
icon: '🏗️',
position: { x: 0, y: 0 },
prerequisites: ['ext-arch.16bit'],
challenges: [],
difficulty: 5,
},
{
id: 'ext-arch.mmu',
discipline: 'extended-architecture',
branch: 'ext-arch',
title: 'MMU y Memoria Virtual',
description: 'Paginación, tablas de páginas, espacios de direcciones — cada proceso cree que tiene toda la RAM',
icon: '🗂️',
position: { x: 0, y: 0 },
prerequisites: ['ext-arch.32bit'],
challenges: [],
difficulty: 5,
},
{
id: 'ext-arch.peripherals',
discipline: 'extended-architecture',
branch: 'ext-arch',
title: 'Bus de Periféricos',
description: 'Buses I/O, DMA, controladores — cómo el CPU habla con el mundo exterior',
icon: '🔌',
position: { x: 0, y: 0 },
prerequisites: ['ext-arch.32bit'],
challenges: [],
difficulty: 4,
},
// =========================================================================
// OPERATING SYSTEMS — Bootloader, Kernel, Filesystem, Processes
// =========================================================================
{
id: 'os.bootloader',
discipline: 'operating-systems',
branch: 'os',
title: 'Bootloader',
description: 'El primer código que ejecuta tu CPU al encender — carga el kernel desde disco',
icon: '🔑',
position: { x: 0, y: 0 },
prerequisites: ['ext-arch.peripherals', 'hlp.c-basics', 'asm.interrupts'],
challenges: [],
difficulty: 4,
},
{
id: 'os.kernel',
discipline: 'operating-systems',
branch: 'os',
title: 'Kernel',
description: 'El corazón del sistema operativo — syscalls, modo kernel vs usuario, scheduler básico',
icon: '🧬',
position: { x: 0, y: 0 },
prerequisites: ['os.bootloader', 'ext-arch.mmu'],
challenges: [],
difficulty: 5,
},
{
id: 'os.processes',
discipline: 'operating-systems',
branch: 'os',
title: 'Procesos y Threads',
description: 'Multitarea, context switching, scheduling — ejecuta varios programas a la vez',
icon: '🔀',
position: { x: 0, y: 0 },
prerequisites: ['os.kernel'],
challenges: [],
difficulty: 5,
},
{
id: 'os.filesystem',
discipline: 'operating-systems',
branch: 'os',
title: 'Sistema de Archivos',
description: 'FAT, inodos, directorios — organiza datos en disco',
icon: '📁',
position: { x: 0, y: 0 },
prerequisites: ['os.kernel'],
challenges: [],
difficulty: 4,
},
{
id: 'os.drivers',
discipline: 'operating-systems',
branch: 'os',
title: 'Drivers',
description: 'Controladores de dispositivo — el puente entre hardware y software',
icon: '🔧',
position: { x: 0, y: 0 },
prerequisites: ['os.kernel', 'ext-arch.peripherals'],
challenges: [],
difficulty: 4,
},
// =========================================================================
// HIGH-LEVEL PROGRAMMING — C, stdlib, data structures, algorithms
// =========================================================================
{
id: 'hlp.c-basics',
discipline: 'high-level-programming',
branch: 'hlp',
title: 'Lenguaje C',
description: 'De ASM a C — variables, tipos, punteros, structs y funciones',
icon: '©️',
position: { x: 0, y: 0 },
prerequisites: ['asm.stack-subroutines'],
challenges: [],
difficulty: 3,
},
{
id: 'hlp.pointers-memory',
discipline: 'high-level-programming',
branch: 'hlp',
title: 'Punteros y Memoria',
description: 'malloc, free, stack vs heap, punteros a punteros — el poder (y peligro) de C',
icon: '🎯',
position: { x: 0, y: 0 },
prerequisites: ['hlp.c-basics'],
challenges: [],
difficulty: 4,
},
{
id: 'hlp.data-structures',
discipline: 'high-level-programming',
branch: 'hlp',
title: 'Estructuras de Datos',
description: 'Arrays, listas enlazadas, pilas, colas, árboles, hash tables en C',
icon: '🌳',
position: { x: 0, y: 0 },
prerequisites: ['hlp.pointers-memory'],
challenges: [],
difficulty: 4,
},
{
id: 'hlp.algorithms',
discipline: 'high-level-programming',
branch: 'hlp',
title: 'Algoritmos',
description: 'Sorting, searching, grafos, complejidad Big O',
icon: '⚡',
position: { x: 0, y: 0 },
prerequisites: ['hlp.data-structures'],
challenges: [],
difficulty: 4,
},
{
id: 'hlp.compiler',
discipline: 'high-level-programming',
branch: 'hlp',
title: 'Tu Compilador',
description: 'Lexer, parser, generación de código — compila C a tu propio ASM',
icon: '🏭',
position: { x: 0, y: 0 },
prerequisites: ['hlp.c-basics', 'cpu.complete'],
challenges: [],
difficulty: 5,
},
// =========================================================================
// NETWORKING — OSI model, protocols, sockets
// =========================================================================
{
id: 'net.physical',
discipline: 'networking',
branch: 'net',
title: 'Capa Física y Enlace',
description: 'Señales eléctricas, tramas, MAC addresses, Ethernet — capas 1 y 2 del modelo OSI',
icon: '🔌',
position: { x: 0, y: 0 },
prerequisites: ['os.drivers', 'signals.sampling'],
challenges: [],
difficulty: 3,
},
{
id: 'net.ip',
discipline: 'networking',
branch: 'net',
title: 'IP y Routing',
description: 'Direcciones IP, subnets, routing tables, ICMP — capa 3, el camino entre nodos',
icon: '🌐',
position: { x: 0, y: 0 },
prerequisites: ['net.physical'],
challenges: [],
difficulty: 3,
},
{
id: 'net.tcp-udp',
discipline: 'networking',
branch: 'net',
title: 'TCP y UDP',
description: 'Puertos, handshake, control de flujo, fiabilidad — capa 4, transporte de datos',
icon: '📡',
position: { x: 0, y: 0 },
prerequisites: ['net.ip'],
challenges: [],
difficulty: 4,
},
{
id: 'net.sockets',
discipline: 'networking',
branch: 'net',
title: 'Sockets',
description: 'La API para enviar y recibir datos — bind, listen, accept, connect, send, recv',
icon: '🔗',
position: { x: 0, y: 0 },
prerequisites: ['net.tcp-udp', 'hlp.c-basics'],
challenges: [],
difficulty: 4,
},
{
id: 'net.dns',
discipline: 'networking',
branch: 'net',
title: 'DNS',
description: 'De nombres a IPs — resuelve dominios, construye un resolver',
icon: '📖',
position: { x: 0, y: 0 },
prerequisites: ['net.sockets'],
challenges: [],
difficulty: 3,
},
// =========================================================================
// WEB — HTTP, Server, Client, Final project
// =========================================================================
{
id: 'web.http',
discipline: 'web',
branch: 'web',
title: 'HTTP',
description: 'Requests, responses, métodos, headers, status codes — el protocolo de la web',
icon: '📄',
position: { x: 0, y: 0 },
prerequisites: ['net.tcp-udp', 'net.dns'],
challenges: [],
difficulty: 3,
},
{
id: 'web.server',
discipline: 'web',
branch: 'web',
title: 'Servidor Web',
description: 'Construye un servidor HTTP desde cero con sockets — escucha, parsea, responde',
icon: '🖧',
position: { x: 0, y: 0 },
prerequisites: ['web.http', 'net.sockets'],
challenges: [],
difficulty: 4,
},
{
id: 'web.html-css',
discipline: 'web',
branch: 'web',
title: 'HTML y CSS',
description: 'Estructura y estilo de páginas web — lo que tu servidor envía al cliente',
icon: '🎨',
position: { x: 0, y: 0 },
prerequisites: ['web.http'],
challenges: [],
difficulty: 2,
},
{
id: 'web.client',
discipline: 'web',
branch: 'web',
title: 'Cliente Web',
description: 'Construye un cliente HTTP — conecta, envía request, parsea response',
icon: '💻',
position: { x: 0, y: 0 },
prerequisites: ['web.http', 'net.sockets'],
challenges: [],
difficulty: 4,
},
{
id: 'web.final',
discipline: 'web',
branch: 'web',
title: '🏆 Cliente ↔ Servidor',
description: 'Tu servidor web sirve una página. Tu cliente la pide. Se comunican por TCP. Todo hecho por ti.',
icon: '🏆',
position: { x: 0, y: 0 },
prerequisites: ['web.server', 'web.client', 'web.html-css'],
challenges: [],
difficulty: 5,
},
]; ];
export const skillEdges: SkillEdge[] = skillNodes export const skillEdges: SkillEdge[] = skillNodes
@@ -237,7 +1043,7 @@ export function getNodeStatus(
const node = getNodeById(nodeId); const node = getNodeById(nodeId);
if (!node) return 'locked'; if (!node) return 'locked';
const allChallengesCompleted = node.challenges.every((c) => const allChallengesCompleted = node.challenges.length > 0 && node.challenges.every((c) =>
completedChallengeIds.includes(`${nodeId}/${c}`) completedChallengeIds.includes(`${nodeId}/${c}`)
); );
if (allChallengesCompleted) return 'completed'; if (allChallengesCompleted) return 'completed';

View File

@@ -1,4 +1,8 @@
import { Challenge, VerificationResult } from '@/types/challenge'; import { Challenge, VerificationResult } from '@/types/challenge';
import { CircuitBuilderContent, CircuitState } from '@/types/circuit';
import { ElectronicsContent, ElectronicCircuitState } from '@/types/electronics';
import { simulateCircuit } from '@/components/workbench/modules/circuit-builder/simulateCircuit';
import { simulateElectronics } from '@/components/workbench/modules/electronics/simulateElectronics';
export function verifyAnswer( export function verifyAnswer(
challenge: Challenge, challenge: Challenge,
@@ -11,6 +15,10 @@ export function verifyAnswer(
return verifyMathInput(content, userAnswer, challenge.xpReward); return verifyMathInput(content, userAnswer, challenge.xpReward);
case 'multiple-choice': case 'multiple-choice':
return verifyMultipleChoice(content, userAnswer, challenge.xpReward); return verifyMultipleChoice(content, userAnswer, challenge.xpReward);
case 'circuit-builder':
return verifyCircuit(content, userAnswer as string, challenge.xpReward);
case 'electronics-lab':
return verifyElectronics(content, userAnswer as string, challenge.xpReward);
default: default:
return { correct: false, message: 'Tipo de reto no soportado', xpEarned: 0 }; return { correct: false, message: 'Tipo de reto no soportado', xpEarned: 0 };
} }
@@ -53,3 +61,107 @@ function verifyMultipleChoice(
return { correct: false, message: 'Incorrecto. Inténtalo de nuevo.', xpEarned: 0 }; return { correct: false, message: 'Incorrecto. Inténtalo de nuevo.', xpEarned: 0 };
} }
function verifyCircuit(
content: CircuitBuilderContent,
userAnswer: string,
xpReward: number
): VerificationResult {
let circuit: CircuitState;
try {
circuit = JSON.parse(userAnswer);
} catch {
return { correct: false, message: 'Circuito inválido', xpEarned: 0 };
}
if (circuit.gates.length === 0) {
return { correct: false, message: 'Coloca al menos una puerta lógica', xpEarned: 0 };
}
let passedRows = 0;
const totalRows = content.truthTable.length;
for (const row of content.truthTable) {
const result = simulateCircuit(circuit, row.inputs);
if (typeof result === 'string') {
return { correct: false, message: result, xpEarned: 0 };
}
if (result.length !== row.outputs.length) {
return { correct: false, message: `Se esperan ${row.outputs.length} salida(s), tu circuito produce ${result.length}`, xpEarned: 0 };
}
const match = result.every((v, i) => v === row.outputs[i]);
if (match) passedRows++;
}
if (passedRows === totalRows) {
let bonus = '';
if (content.maxGates && circuit.gates.length <= content.maxGates) {
bonus = ` (¡con solo ${circuit.gates.length} puerta${circuit.gates.length === 1 ? '' : 's'}!)`;
}
return { correct: true, message: `¡Circuito correcto! 🎉${bonus}`, xpEarned: xpReward };
}
return {
correct: false,
message: `Tu circuito pasa ${passedRows} de ${totalRows} combinaciones. Revisa las conexiones.`,
xpEarned: 0,
};
}
function verifyElectronics(
content: ElectronicsContent,
userAnswer: string,
xpReward: number
): VerificationResult {
let circuit: ElectronicCircuitState;
try {
circuit = JSON.parse(userAnswer);
} catch {
return { correct: false, message: 'Circuito inválido', xpEarned: 0 };
}
if (circuit.components.length === 0) {
return { correct: false, message: 'Coloca al menos un componente', xpEarned: 0 };
}
const result = simulateElectronics(circuit);
if (!result.success) {
return { correct: false, message: result.error ?? 'Error de simulación', xpEarned: 0 };
}
// Collect all meter readings by type
const voltmeterReadings: number[] = [];
const ammeterReadings: number[] = [];
for (const [, reading] of result.meterReadings) {
if (reading.unit === 'V') voltmeterReadings.push(reading.value);
if (reading.unit === 'mA') ammeterReadings.push(reading.value);
}
let passed = 0;
const messages: string[] = [];
for (const probe of content.probes) {
const readings = probe.type === 'voltmeter' ? voltmeterReadings : ammeterReadings;
const instrumentName = probe.type === 'voltmeter' ? 'voltímetro' : 'amperímetro';
if (readings.length === 0) {
messages.push(`Coloca un ${instrumentName} para medir: ${probe.label}`);
continue;
}
// Check if ANY meter of the right type reads the expected value
const match = readings.some((r) => Math.abs(r - probe.expected) <= probe.tolerance);
if (match) {
passed++;
} else {
messages.push(`${probe.label}: esperado ${probe.expected}${probe.unit}, tu ${instrumentName} lee ${readings.map(r => r.toFixed(1) + probe.unit).join(', ')}`);
}
}
if (passed === content.probes.length) {
return { correct: true, message: '¡Circuito correcto! 🎉', xpEarned: xpReward };
}
const msg = messages.length > 0 ? messages[0] : `${passed} de ${content.probes.length} mediciones correctas.`;
return { correct: false, message: msg, xpEarned: 0 };
}

View File

@@ -126,7 +126,7 @@ export const useProgressStore = create<ProgressStore>()(
const state = get(); const state = get();
const completedNodeIds: string[] = []; const completedNodeIds: string[] = [];
for (const node of skillNodes) { for (const node of skillNodes) {
const allDone = node.challenges.every( const allDone = node.challenges.length > 0 && node.challenges.every(
(cId) => state.completedChallenges[`${node.id}/${cId}`] (cId) => state.completedChallenges[`${node.id}/${cId}`]
); );
if (allDone) completedNodeIds.push(node.id); if (allDone) completedNodeIds.push(node.id);
@@ -141,7 +141,7 @@ export const useProgressStore = create<ProgressStore>()(
const completedNodeIds = get().getCompletedNodeIds(); const completedNodeIds = get().getCompletedNodeIds();
const allDone = node.challenges.every( const allDone = node.challenges.length > 0 && node.challenges.every(
(cId) => state.completedChallenges[`${node.id}/${cId}`] (cId) => state.completedChallenges[`${node.id}/${cId}`]
); );
if (allDone) return 'completed'; if (allDone) return 'completed';

View File

@@ -3,6 +3,7 @@ export type WorkbenchType =
| 'multiple-choice' | 'multiple-choice'
| 'code-editor' | 'code-editor'
| 'circuit-builder' | 'circuit-builder'
| 'electronics-lab'
| 'physics-sim' | 'physics-sim'
| 'signal-playground' | 'signal-playground'
| 'graph-plotter'; | 'graph-plotter';
@@ -24,7 +25,10 @@ export interface MultipleChoiceContent {
correctIndex: number; correctIndex: number;
} }
export type ChallengeContent = MathInputContent | MultipleChoiceContent; import { CircuitBuilderContent } from './circuit';
import { ElectronicsContent } from './electronics';
export type ChallengeContent = MathInputContent | MultipleChoiceContent | CircuitBuilderContent | ElectronicsContent;
export interface Challenge { export interface Challenge {
id: string; id: string;

51
src/types/circuit.ts Normal file
View File

@@ -0,0 +1,51 @@
export type GateType = 'AND' | 'OR' | 'NOT' | 'NAND' | 'NOR' | 'XOR' | 'XNOR';
export interface PlacedGate {
id: string;
type: GateType;
x: number;
y: number;
}
export interface Wire {
id: string;
from: string; // port id: "input-0", "gate-1:out", "gate-2:out"
to: string; // port id: "output-0", "gate-1:in-0", "gate-1:in-1"
}
export interface CircuitState {
gates: PlacedGate[];
wires: Wire[];
}
export interface TruthTableRow {
inputs: boolean[];
outputs: boolean[];
}
export interface CircuitBuilderContent {
type: 'circuit-builder';
availableGates: GateType[];
inputLabels: string[];
outputLabels: string[];
truthTable: TruthTableRow[];
maxGates?: number;
}
// Gate metadata
export function gateInputCount(type: GateType): number {
return type === 'NOT' ? 1 : 2;
}
export function evaluateGate(type: GateType, inputs: boolean[]): boolean {
const [a, b] = inputs;
switch (type) {
case 'AND': return a && b;
case 'OR': return a || b;
case 'NOT': return !a;
case 'NAND': return !(a && b);
case 'NOR': return !(a || b);
case 'XOR': return a !== b;
case 'XNOR': return a === b;
}
}

91
src/types/electronics.ts Normal file
View File

@@ -0,0 +1,91 @@
// === Component types for the electronics simulator ===
export type ElectronicComponentType =
| 'voltage-source' // DC voltage source
| 'resistor' // Resistor (Ohms)
| 'capacitor' // Capacitor (Farads) — in DC steady state acts as open circuit
| 'led' // LED (simplified: threshold voltage + series resistance)
| 'switch' // Manual on/off switch
| 'nmos' // N-channel MOSFET
| 'pmos' // P-channel MOSFET
| 'ground' // Ground reference
| 'voltmeter' // Measures voltage between two points
| 'ammeter'; // Measures current through a branch
export interface ElectronicComponent {
id: string;
type: ElectronicComponentType;
x: number;
y: number;
rotation: 0 | 90 | 180 | 270;
value?: number; // resistance in Ohms, voltage in V
label?: string;
}
export interface ElectronicWire {
id: string;
from: string; // "comp-1:a" or "comp-1:b" (terminal a/b)
to: string;
}
export interface ElectronicCircuitState {
components: ElectronicComponent[];
wires: ElectronicWire[];
}
// === Simulation result ===
export interface SimulationResult {
success: boolean;
error?: string;
nodeVoltages: Map<string, number>; // terminalId -> voltage
branchCurrents: Map<string, number>; // componentId -> current through it
meterReadings: Map<string, { value: number; unit: string }>; // meterId -> reading
}
// === Probe: what we check in a challenge ===
export interface ProbeCheck {
type: 'voltmeter' | 'ammeter'; // must use the corresponding instrument
expected: number; // expected reading
tolerance: number; // allowed error
label: string; // "Voltaje en R2" etc
unit: string; // 'V' or 'mA'
}
export interface ElectronicsContent {
type: 'electronics-lab';
availableComponents: ElectronicComponentType[];
probes: ProbeCheck[];
preplacedComponents?: ElectronicComponent[];
preplacedWires?: ElectronicWire[];
}
// === Component metadata ===
export function getTerminals(type: ElectronicComponentType): string[] {
switch (type) {
case 'voltage-source': return ['pos', 'neg'];
case 'resistor': return ['a', 'b'];
case 'capacitor': return ['a', 'b'];
case 'led': return ['anode', 'cathode'];
case 'switch': return ['a', 'b'];
case 'ground': return ['gnd'];
case 'nmos': return ['gate', 'drain', 'source'];
case 'pmos': return ['gate', 'drain', 'source'];
case 'voltmeter': return ['pos', 'neg'];
case 'ammeter': return ['a', 'b'];
}
}
export function componentLabel(type: ElectronicComponentType): string {
switch (type) {
case 'voltage-source': return 'Fuente V';
case 'resistor': return 'Resistencia';
case 'capacitor': return 'Condensador';
case 'led': return 'LED';
case 'switch': return 'Interruptor';
case 'ground': return 'GND';
case 'nmos': return 'NMOS';
case 'pmos': return 'PMOS';
case 'voltmeter': return 'Voltímetro';
case 'ammeter': return 'Amperímetro';
}
}

View File

@@ -4,8 +4,20 @@ export type Discipline =
| 'programming' | 'programming'
| 'physics' | 'physics'
| 'electronics' | 'electronics'
| 'cryptography' | 'digital-circuits'
| 'sequential-circuits'
| 'computer-architecture'
| 'low-level-programming'
| 'signal-processing' | 'signal-processing'
| 'audio-synthesis'
| 'graphics'
| 'game-dev'
| 'extended-architecture'
| 'operating-systems'
| 'high-level-programming'
| 'networking'
| 'web'
| 'cryptography'
| 'statistics'; | 'statistics';
export interface SkillNode { export interface SkillNode {

37
todo.md Normal file
View File

@@ -0,0 +1,37 @@
# MathTree — Roadmap
## En progreso
- [ ] Nuevos nodos: Puertas Lógicas, Circuitos Combinacionales
- [ ] Workbench: Circuit Builder (drag-and-drop de puertas, cables, verificación por tabla de verdad)
## Contenido
- [ ] Generación procedural de retos (sumas, restas, etc. aleatorias con dificultad escalada)
- [ ] Más retos por nodo (objetivo: 8-10 por nodo mínimo)
- [ ] Explicaciones "Aprende primero" en todos los retos, no solo los primeros
- [ ] Nodos de Geometría (ángulos, áreas, trigonometría)
- [ ] Nodos de Estadística (media, mediana, probabilidad)
- [ ] Nodos de Programación (pseudocódigo, variables, bucles)
## Nuevos Workbenches
- [ ] Circuit Builder — puertas lógicas, cables, tablas de verdad
- [ ] Code Editor — Monaco editor con test cases para nodos de programación
- [ ] Graph/Plotter — plotting interactivo estilo Desmos para funciones y geometría
- [ ] Physics Sim — canvas 2D con motor de física para cinemática y balística
## UX / Pulido
- [ ] Animaciones al desbloquear nodos, ganar XP, subir de nivel
- [ ] Sonidos de feedback (correcto/incorrecto/desbloqueo)
- [ ] Landing page
- [ ] Responsive / mobile improvements
- [ ] Dark/light mode toggle
## Persistencia
- [ ] Supabase auth (email/password)
- [ ] Sync progreso a la nube
- [ ] Página de perfil con avatar y username
## Futuro
- [ ] Features sociales (compartir soluciones, duelos)
- [ ] Leaderboards semanales
- [ ] Integrar logic-gates como módulo embebido del circuit builder
- [ ] Integrar reaktor como módulo embebido del signal playground