From 0bd5809829f470274692ca8f94ef2845b9188503 Mon Sep 17 00:00:00 2001 From: nakomochi Date: Mon, 6 Apr 2026 23:12:28 +0900 Subject: [PATCH] cone animation along edges --- app/lib/ice.unit.test.ts | 2 +- app/lib/icemake.ts | 97 ++++++---- app/routes/stage.$id.tsx | 387 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 428 insertions(+), 58 deletions(-) diff --git a/app/lib/ice.unit.test.ts b/app/lib/ice.unit.test.ts index 9bc6692..d313cd6 100644 --- a/app/lib/ice.unit.test.ts +++ b/app/lib/ice.unit.test.ts @@ -19,7 +19,7 @@ describe("icemake", () => { for (const color of Object.keys(mission) as Array) { const expected = mission[color] ?? []; - const actual = icemake([color], stage, graph, firstComponentId)[color]; + const actual = icemake([color], stage, graph, firstComponentId).result[color]; expect(actual).toEqual(expected); } }); diff --git a/app/lib/icemake.ts b/app/lib/icemake.ts index 39c6675..b0a2aea 100644 --- a/app/lib/icemake.ts +++ b/app/lib/icemake.ts @@ -6,27 +6,51 @@ export type ComponentGraphNode = { childrenIds: number | null | { true: number | null; false: number | null }; }; +export type ExecutionStep = { + componentIndex: number; + stack: Flavor[]; + branchTaken?: boolean; +}; + +export type IcemakeResult = { + result: Partial>; + traces: { color: ConeColor; steps: ExecutionStep[] }[]; +}; + export function icemake( colors: ConeColor[], stage: number, graph?: Record, firstComponentId?: number, -): Partial> { +): IcemakeResult { const stageData = STAGES[stage]; - if (!stageData) return {}; + if (!stageData) return { result: {}, traces: [] }; if (graph && firstComponentId !== undefined) { - return Object.fromEntries( - colors.map((color) => [ + const traces = colors.map((color) => { + const steps = runGraphExecution( + stageData.components, + color, + graph, + firstComponentId, + ); + return { color, steps }; + }); + const result = Object.fromEntries( + traces.map(({ color, steps }) => [ color, - runGraphExecution(stageData.components, color, graph, firstComponentId), + steps.length > 0 ? steps[steps.length - 1].stack : [], ]), ); + return { result, traces }; } - return Object.fromEntries( - colors.map((color) => [color, stageData.mission[color] ?? []]), - ); + return { + result: Object.fromEntries( + colors.map((color) => [color, stageData.mission[color] ?? []]), + ), + traces: [], + }; } function runGraphExecution( @@ -34,9 +58,9 @@ function runGraphExecution( color: ConeColor, graph: Record, firstComponentId: number, -): Flavor[] { +): ExecutionStep[] { const stack: Flavor[] = []; - // Map> + const steps: ExecutionStep[] = []; const visited: Map> = new Map(); let currentId: number | null = firstComponentId; @@ -48,51 +72,37 @@ function runGraphExecution( const node: ComponentGraphNode | undefined = graph[currentId]; if (!component || !node) break; - // Check if we've visited this component with this exact stack state const stackKey = JSON.stringify(stack); - if (!visited.has(currentId)) { - visited.set(currentId, new Set()); - } - const componentVisited = visited.get(currentId)!; - if (componentVisited.has(stackKey)) { - console.warn(`Infinite loop detected at component ${currentId} with stack state ${stackKey}`); - // Infinite loop detected: same component with same stack state - break; - } + if (!visited.has(currentId)) visited.set(currentId, new Set()); + const componentVisited = visited.get(currentId); + if (!componentVisited || componentVisited.has(stackKey)) break; componentVisited.add(stackKey); switch (component.type) { case "push": - if (stack.length < 5) { - stack.push(component.flavor); - } + if (stack.length < 5) stack.push(component.flavor); break; case "pop": - if (stack.length > 0 && stack[stack.length - 1] === component.flavor) { + if (stack.length > 0 && stack[stack.length - 1] === component.flavor) stack.pop(); - } break; case "if": break; } const children: ComponentGraphNode["childrenIds"] = node.childrenIds; - if (children == null) break; - if (typeof children === "number") { - currentId = children; - } else { - let condition: boolean = false; + let branchTaken: boolean | undefined; + if (children != null && typeof children !== "number") { + let condition = false; if (component.type === "if") { - const cond : ConeColor | Flavor | Flavor[] | number = component.condition ; + const cond: ConeColor | Flavor | Flavor[] | number = component.condition; if (typeof cond === "string") { - if (coneColors.includes(cond as ConeColor)) { - condition = color === cond; - } else if (flavors.includes(cond as Flavor)) { + if (coneColors.includes(cond as ConeColor)) condition = color === cond; + else if (flavors.includes(cond as Flavor)) condition = stack.length > 0 && stack[stack.length - 1] === cond; - } } else if (Array.isArray(cond)) { - for (let i = 0; i < stack.length-cond.length+1; i++) { + for (let i = 0; i <= stack.length - cond.length; i++) { if (stack.slice(i, i + cond.length).every((f, j) => f === cond[j])) { condition = true; break; @@ -102,10 +112,19 @@ function runGraphExecution( condition = stack.length >= cond; } } - currentId = condition ? children.true : children.false; + branchTaken = condition; + } + + steps.push({ componentIndex: currentId, stack: [...stack], branchTaken }); + + if (children == null) break; + + if (typeof children === "number") { + currentId = children; + } else { + currentId = branchTaken ? children.true : children.false; } } - console.log(`Finished execution for color ${color} with final stack:`, stack); - return stack; + return steps; } diff --git a/app/routes/stage.$id.tsx b/app/routes/stage.$id.tsx index 4b7a01d..c2f7592 100644 --- a/app/routes/stage.$id.tsx +++ b/app/routes/stage.$id.tsx @@ -1,7 +1,7 @@ import { STAGES, type Component } from "~/stages"; import type { Route } from "./+types/stage.$id"; import { useNavigate } from "react-router"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ReactFlow, ReactFlowProvider, @@ -16,10 +16,11 @@ import { type NodeProps, type OnConnect, type Edge, + ConnectionLineType, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import type { ConeColor, Flavor } from "~/stages"; -import { icemake, type ComponentGraphNode } from "~/lib/icemake"; +import { icemake, type ComponentGraphNode, type ExecutionStep } from "~/lib/icemake"; let id = 0; const getId = () => `node_${id++}`; @@ -196,6 +197,209 @@ function checkClear( return true; } +type AnimSegment = { + stackAfter: Flavor[]; + durationMs: number; +} & ( + | { kind: "edge"; pathEl: SVGPathElement; totalLength: number } + | { kind: "linear"; x1: number; y1: number; x2: number; y2: number } +); + +type FlyingCone = { + id: number; + color: ConeColor; + segments: AnimSegment[]; + currentSegment: number; + segmentStartTime: number; +}; + +type FlyingConeRender = { + id: number; + color: ConeColor; + x: number; + y: number; + stack: Flavor[]; +}; + +type AnimState = { + cones: FlyingCone[]; + spawnQueue: { color: ConeColor; segments: AnimSegment[] }[]; + nextSpawnTime: number; + nextId: number; + result: Partial>; +}; + +const SPEED_PPS = 100; +const SPAWN_INTERVAL_MS = 2000; +const NODE_WIDTH_PX = 96; +const SPLIT_Y_OFFSET = 20; +const PAUSE_AT_NODE_MS = 200; +const PAUSE_AT_TERMINAL_MS = 600; +const MIN_SEGMENT_MS = 80; + +function getEdgePathEl(edgeId: string): SVGPathElement | null { + return ( + (document.querySelector( + `[data-testid="rf__edge-${edgeId}"] .react-flow__edge-path`, + ) as SVGPathElement | null) ?? + (document.querySelector( + `.react-flow__edge[data-id="${edgeId}"] .react-flow__edge-path`, + ) as SVGPathElement | null) + ); +} + +function lengthMs(dist: number): number { + return Math.max((dist / SPEED_PPS) * 1000, MIN_SEGMENT_MS); +} + +function distMs(x1: number, y1: number, x2: number, y2: number): number { + return lengthMs(Math.hypot(x2 - x1, y2 - y1)); +} + +function findEdgeByBranch( + allEdges: Edge[], + sourceId: string, + targetId: string | undefined, + branch: boolean | undefined, +): Edge | undefined { + return allEdges.find((e) => { + if (e.source !== sourceId) return false; + if (targetId !== undefined && e.target !== targetId) return false; + if (branch === undefined) return true; + return e.sourceHandle === (branch ? "true" : "false"); + }); +} + +function buildFullPath( + trace: ExecutionStep[], + allNodes: AppNode[], + allEdges: Edge[], +): AnimSegment[] { + if (trace.length === 0) return []; + + const nodeByComp = new Map(); + for (const n of allNodes) { + if (n.id !== "start") nodeByComp.set(n.data.componentIndex, n); + } + + // Collect resolved edge paths for each step + type ResolvedEdge = { pathEl: SVGPathElement; totalLength: number }; + const edgePaths: ResolvedEdge[] = []; + + const firstNode = nodeByComp.get(trace[0].componentIndex); + if (!firstNode) return []; + const startEdge = allEdges.find( + (e) => e.source === "start" && e.target === firstNode.id, + ); + if (!startEdge) return []; + const startPathEl = getEdgePathEl(startEdge.id); + if (!startPathEl) return []; + edgePaths.push({ pathEl: startPathEl, totalLength: startPathEl.getTotalLength() }); + + for (let i = 1; i < trace.length; i++) { + const fromNode = nodeByComp.get(trace[i - 1].componentIndex); + const toNode = nodeByComp.get(trace[i].componentIndex); + if (!fromNode || !toNode) break; + + const edge = findEdgeByBranch( + allEdges, fromNode.id, toNode.id, trace[i - 1].branchTaken, + ); + if (!edge) break; + const pathEl = getEdgePathEl(edge.id); + if (!pathEl) break; + edgePaths.push({ pathEl, totalLength: pathEl.getTotalLength() }); + } + + const segments: AnimSegment[] = []; + + for (let i = 0; i < edgePaths.length; i++) { + const { pathEl, totalLength } = edgePaths[i]; + const prevStack = i > 0 ? trace[i - 1].stack : []; + const curStack = trace[i].stack; + + // 1. Edge segment (old stack — not yet processed) + segments.push({ + kind: "edge", + pathEl, + totalLength, + stackAfter: prevStack, + durationMs: lengthMs(totalLength), + }); + + const inp = pathEl.getPointAtLength(totalLength); + + // Determine output handle position + let outX: number; + let outY: number; + + if (i < edgePaths.length - 1) { + const p = edgePaths[i + 1].pathEl.getPointAtLength(0); + outX = p.x; + outY = p.y; + } else { + outX = inp.x + NODE_WIDTH_PX; + outY = inp.y; + const lastNode = nodeByComp.get(trace[i].componentIndex); + if (lastNode?.type === "split" && trace[i].branchTaken !== undefined) { + outY = trace[i].branchTaken ? inp.y - SPLIT_Y_OFFSET : inp.y + SPLIT_Y_OFFSET; + } + if (lastNode) { + const outEdge = findEdgeByBranch( + allEdges, lastNode.id, undefined, trace[i].branchTaken, + ); + if (outEdge) { + const outPath = getEdgePathEl(outEdge.id); + if (outPath) { + const p = outPath.getPointAtLength(0); + outX = p.x; + outY = p.y; + } + } + } + } + + // Center = midpoint X, input Y (split point for if-nodes) + const cx = (inp.x + outX) / 2; + const cy = inp.y; + + // 2. Input handle → center (old stack still) + segments.push({ + kind: "linear", + x1: inp.x, y1: inp.y, x2: cx, y2: cy, + stackAfter: prevStack, + durationMs: distMs(inp.x, inp.y, cx, cy), + }); + + // 3. Pause at center (stack updates here) + segments.push({ + kind: "linear", + x1: cx, y1: cy, x2: cx, y2: cy, + stackAfter: curStack, + durationMs: PAUSE_AT_NODE_MS, + }); + + // 4. Center → output handle (new stack, diagonal for split nodes) + segments.push({ + kind: "linear", + x1: cx, y1: cy, x2: outX, y2: outY, + stackAfter: curStack, + durationMs: distMs(cx, cy, outX, outY), + }); + + // 5. Terminal pause (last node only) + if (i === edgePaths.length - 1) { + segments.push({ + kind: "linear", + x1: outX, y1: outY, x2: outX, y2: outY, + stackAfter: curStack, + durationMs: PAUSE_AT_TERMINAL_MS, + }); + } + } + + return segments; +} + function StageInner({ stageId, stageData, @@ -217,15 +421,19 @@ function StageInner({ data: { label: "始点", component: { type: "push", flavor: "vanilla" } as Component, - componentIndex: -1, // -1 indicates start node + componentIndex: -1, }, draggable: false, }, ]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const { screenToFlowPosition } = useReactFlow(); + const { screenToFlowPosition, getViewport } = useReactFlow(); const [isClear, setIsClear] = useState(false); const [failMessage, setFailMessage] = useState(""); + const [isAnimating, setIsAnimating] = useState(false); + const [flyingCones, setFlyingCones] = useState([]); + const animRef = useRef(null); + const rafRef = useRef(null); const onConnect: OnConnect = useCallback( (params) => setEdges((eds) => addEdge(params, eds)), @@ -342,12 +550,18 @@ function StageInner({ }; } - console.log(components); - return components; }; + useEffect(() => { + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + }, []); + const handleExecute = () => { + if (isAnimating) return; + const components = generateComponents(nodes, edges, stageData.components); const startEdge = edges.find((e) => e.source === "start"); @@ -363,16 +577,121 @@ function StageInner({ } const firstComponentId = firstNode.data.componentIndex; - const colors = Object.keys(stageData.mission) as ConeColor[]; - const result = icemake(colors, stageId, components, firstComponentId); - if (checkClear(stageData.mission, result)) { - setIsClear(true); - setFailMessage(""); - } else { - setIsClear(false); - setFailMessage("不一致です。もう一度試してください。"); - } + const { result, traces } = icemake(colors, stageId, components, firstComponentId); + + const spawnQueue = traces.map(({ color, steps }) => ({ + color, + segments: buildFullPath(steps, nodes, edges), + })); + + setFailMessage(""); + setIsClear(false); + setIsAnimating(true); + + animRef.current = { + cones: [], + spawnQueue, + nextSpawnTime: 0, + nextId: 0, + result, + }; + + const loop = (timestamp: number) => { + const anim = animRef.current; + if (!anim) { + setIsAnimating(false); + setFlyingCones([]); + return; + } + + // Spawn cones at intervals + if ( + anim.spawnQueue.length > 0 && + (anim.cones.length === 0 || timestamp >= anim.nextSpawnTime) + ) { + const next = anim.spawnQueue.shift()!; + anim.cones.push({ + id: anim.nextId++, + color: next.color, + segments: next.segments, + currentSegment: 0, + segmentStartTime: timestamp, + }); + anim.nextSpawnTime = timestamp + SPAWN_INTERVAL_MS; + } + + // Compute positions + const viewport = getViewport(); + const rendered: FlyingConeRender[] = []; + + for (const cone of anim.cones) { + if (cone.currentSegment >= cone.segments.length) continue; + + const seg = cone.segments[cone.currentSegment]; + const elapsed = timestamp - cone.segmentStartTime; + const progress = Math.min(elapsed / seg.durationMs, 1); + + let px: number | null = null; + let py: number | null = null; + + if (seg.kind === "edge") { + const pt = seg.pathEl.getPointAtLength( + progress * seg.totalLength, + ); + px = pt.x; + py = pt.y; + } else { + px = seg.x1 + (seg.x2 - seg.x1) * progress; + py = seg.y1 + (seg.y2 - seg.y1) * progress; + } + + if (px !== null && py !== null) { + const stack = + progress >= 1 + ? seg.stackAfter + : cone.currentSegment > 0 + ? cone.segments[cone.currentSegment - 1].stackAfter + : []; + + rendered.push({ + id: cone.id, + color: cone.color, + x: px * viewport.zoom + viewport.x, + y: py * viewport.zoom + viewport.y, + stack, + }); + } + + if (progress >= 1) { + cone.currentSegment++; + cone.segmentStartTime = timestamp; + } + } + + // Remove finished cones + anim.cones = anim.cones.filter( + (c) => c.currentSegment < c.segments.length, + ); + setFlyingCones(rendered); + + // All done? + if (anim.cones.length === 0 && anim.spawnQueue.length === 0) { + animRef.current = null; + setIsAnimating(false); + setFlyingCones([]); + if (checkClear(stageData.mission, anim.result)) { + setIsClear(true); + } else { + setFailMessage("不一致です。もう一度試してください。"); + } + return; + } + + rafRef.current = requestAnimationFrame(loop); + }; + + rafRef.current = requestAnimationFrame(loop); }; const nextStageExists = STAGES[stageId + 1] !== undefined; @@ -409,7 +728,7 @@ function StageInner({ -
+
+ + {flyingCones.map((cone) => ( +
+
+ {[...cone.stack].reverse().map((flavor, i) => ( + + ))} + +
+
+ ))}
@@ -463,8 +809,13 @@ function StageInner({ {/* Execute button */}
-