pixi-vn-minigames

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Pixi'VN Minigames

Pixi'VN 小游戏

Pixi'VN has no dedicated minigame API — a minigame is just PixiJS code (and, for its HUD/UI, a regular framework UI) wired into the game through the same primitives used elsewhere: canvas layers, hotkeys, and navigation. This skill is about how those pieces fit together for a minigame specifically, not about introducing new APIs.
Pixi'VN 没有专门的小游戏 API——小游戏本质上就是 PixiJS 代码(其 HUD/UI 使用常规框架 UI),并通过与其他地方相同的基础机制接入游戏:画布图层、热键和导航。本 skill 讲解的是如何专门围绕小游戏组合这些部分,而不是引入新的 API。

When to use this skill

何时使用此 skill

Load this skill whenever a task involves building or wiring in a minigame: a snake/arcade-style game, a timing/reaction challenge, a puzzle, a quiz mini-mode, or anything else the player breaks away from the main narration to play. Not for the narrative canvas itself (
pixi-vn-canvas
) or for general app UI screens unrelated to a minigame (
pixi-vn-ui
).
只要任务涉及构建或接入小游戏,就应加载此 skill:贪吃蛇/街机风格游戏、计时/反应挑战、益智游戏、问答模式,或任何玩家脱离主叙事来游玩的内容。不适用于叙事画布本身(
pixi-vn-canvas
),也不适用于与小游戏无关的常规应用 UI 页面(
pixi-vn-ui
)。

Core mental model

核心思维模型

  • A minigame renders on its own PixiJS layer, not on
    canvas.gameLayer
    .
    gameLayer
    is the save-able narrative scene graph (backgrounds, characters — see
    pixi-vn-canvas
    ); a minigame's moving pieces (snake segments, falling blocks, ...) are transient and don't belong there. Use
    canvas.layers.add(name, new Container())
    to get a separate, non-save-able layer for the minigame, exactly like a PixiJS UI layer (
    pixi-vn-ui
    ) — the official template even reserves a constant for it,
    CANVAS_MINIGAME_LAYER_NAME
    .
  • The minigame's HUD/menus (score, game-over overlay, control buttons) should be regular UI, not PixiJS — built with React/Vue/whatever the project's UI already uses (
    pixi-vn-ui
    ), positioned over the canvas the same way any other UI is.
  • A minigame is launched from the narrative, then the player returns to it. The recommended approach is to link the minigame to its own route (e.g.
    /minigame/snake
    ) and navigate to it from a step, then navigate back when it ends — see
    pixi-vn-ui
    's "Navigating between UI screens" section and interface-navigate. Every minigame example below is written as a route file for this reason, not a bare component.
  • Saving and restoring the minigame's current state (score, board, position, ...) is entirely the developer's responsibility.
    Game.exportGameState()
    does not know about a minigame in progress — see
    pixi-vn-saves
    . If a minigame's progress must survive a save/reload, persist it yourself (e.g. via game storage, see
    pixi-vn-storage
    ).
  • 小游戏应渲染在独立的 PixiJS 图层上,而不是
    canvas.gameLayer
    。
    gameLayer
    是可保存的叙事场景图(背景、角色——参见
    pixi-vn-canvas
    );小游戏中的移动元素(贪吃蛇身体、下落的方块等)都是临时性的,不应放在其中。使用
    canvas.layers.add(name, new Container())
    创建一个独立且不可保存的小游戏图层,其方式与 PixiJS UI 图层(
    pixi-vn-ui
    )完全相同——官方模板甚至为此预留了常量
    CANVAS_MINIGAME_LAYER_NAME
    。
  • 小游戏的 HUD/菜单(分数、游戏结束遮罩、控制按钮)应使用常规 UI,而不是 PixiJS——使用项目已有的 React/Vue 等 UI 技术栈构建(
    pixi-vn-ui
    ),并像其他 UI 一样叠加在画布上。
  • 小游戏从叙事中启动,结束后玩家再返回叙事。 推荐做法是为小游戏关联一个独立路由(例如
    /minigame/snake
    ),从一个步骤导航进入,并在小游戏结束后导航返回——参见
    pixi-vn-ui
    的“在 UI 页面之间导航”章节以及 interface-navigate。出于这个原因,下面的每个小游戏示例都写成了路由文件,而不是单独的组件。
  • 保存和恢复小游戏当前状态(分数、棋盘、位置等)完全由开发者负责。
    Game.exportGameState()
    并不知道正在运行的小游戏——参见
    pixi-vn-saves
    。如果小游戏进度必须在保存/重新加载后仍然存在,请自行持久化(例如通过 game storage,参见
    pixi-vn-storage
    )。

Routing structure: one route per minigame, plus a shared layout route

路由结构:每个小游戏一个路由,再加一个共享布局路由

With file-based routing (e.g. TanStack Router, used by the official templates), each minigame gets its own file under
src/routes/minigame/
, so a project with several minigames ends up with
/minigame/snake
,
/minigame/quiz
, etc.:
tsx
import { createFileRoute } from "@tanstack/react-router";
import SnakeGame from "@/screens/minigames/SnakeGame";

export const Route = createFileRoute("/minigame/snake")({
  component: SnakeGame,
});
Anything shared by every minigame (pausing narration/audio while one is open, common hotkeys, a shared HUD wrapper) doesn't belong copy-pasted into each of those files. Factor it into a single pathless layout route,
src/routes/minigame.tsx
, that renders an
<Outlet />
for whichever minigame route is active — the same pattern the official React template already uses for
/game
(
src/routes/game.tsx
wraps every
/game/*
screen with
usePauseGameWhenMenuIsOpen
+
useGameHotkeys
+
<Outlet />
):
tsx
import { createFileRoute, Outlet } from "@tanstack/react-router";

export const Route = createFileRoute("/minigame")({
  component: MinigameLayout,
});

function MinigameLayout() {
  // hooks/providers shared by every minigame go here
  return <Outlet />;
}
What actually goes in
src/routes/minigame.tsx
is a judgment call to make while building the first minigame, not a file to scaffold reflexively
— work through these questions before adding anything to it:
  • Is there actually anything shared across minigames? If the game's settings menu, pause menu, or quick-actions wheel aren't reachable while a minigame is open, and there's no common hotkey/HUD/provider every minigame needs,
    src/routes/minigame.tsx
    has nothing to do — leave it empty (just the
    <Outlet />
    ) or skip creating it at all and let each minigame route stand alone.
  • Are there menus specific to minigames only (a minigame pause menu, a "quit minigame and return to story" confirmation, a minigame-specific settings panel)? If so, that's exactly what belongs in the shared layout, the same way
    src/routes/game.tsx
    centralizes
    usePauseGameWhenMenuIsOpen
    and
    useGameHotkeys
    for every
    /game/*
    screen.
  • Should the same menus as
    src/routes/game.tsx
    (settings, save/load, quick actions) be reachable from inside a minigame?
    If yes, don't assume they behave identically — check case by case whether each one still makes sense mid-minigame. Saving is the clearest example:
    Game.exportGameState()
    knows nothing about in-progress minigame state (see the Gotchas section below), so a save triggered from inside a minigame either needs to be disabled/hidden there, or the minigame needs to persist its own state first (
    pixi-vn-storage
    ) so the save is still meaningful. Don't wire in the game's menus unchanged and assume they "just work" inside a minigame.
在基于文件的路由下(例如官方模板使用的 TanStack Router),每个小游戏在
src/routes/minigame/
下都有自己的文件,因此包含多个小游戏的项目最终会有
/minigame/snake
、
/minigame/quiz
等路由:
tsx
import { createFileRoute } from "@tanstack/react-router";
import SnakeGame from "@/screens/minigames/SnakeGame";

export const Route = createFileRoute("/minigame/snake")({
  component: SnakeGame,
});
所有小游戏共享的内容(小游戏打开时暂停叙事/音频、通用热键、共享 HUD 包装组件)不应复制粘贴到这些文件中的每一个里。将其提取到一个无路径布局路由
src/routes/minigame.tsx
,由它为当前激活的小游戏路由渲染
<Outlet />
。官方 React 模板对
/game
已经采用相同模式(
src/routes/game.tsx
使用
usePauseGameWhenMenuIsOpen
+
useGameHotkeys
+
<Outlet />
包装所有
/game/*
页面):
tsx
import { createFileRoute, Outlet } from "@tanstack/react-router";

export const Route = createFileRoute("/minigame")({
  component: MinigameLayout,
});

function MinigameLayout() {
  // hooks/providers shared by every minigame go here
  return <Outlet />;
}
src/routes/minigame.tsx
中究竟应放什么,需要在构建_第一个_小游戏时自行判断,而不是不加思考地创建该文件
——添加任何内容之前,请先逐一考虑以下问题:
  • 小游戏之间是否确实存在共享内容? 如果小游戏打开时无法访问游戏设置菜单、暂停菜单或快捷操作轮盘,而且没有每个小游戏都需要使用的通用热键、HUD 或 provider,那么
    src/routes/minigame.tsx
    就无事可做——让它保持为空(仅包含
    <Outlet />
    ),或者干脆不创建它,让每个小游戏路由独立存在。
  • 是否存在仅供小游戏使用的菜单? 例如小游戏暂停菜单、“退出小游戏并返回故事”确认框或小游戏专属设置面板。如果有,这些正是应该放入共享布局的内容,与
    src/routes/game.tsx
    为所有
    /game/*
    页面集中管理
    usePauseGameWhenMenuIsOpen
    和
    useGameHotkeys
    的方式相同。
  • 是否应该让玩家在小游戏内访问与
    src/routes/game.tsx
    相同的菜单(设置、保存/读取、快速操作)?
    如果应该,不要假定它们的行为完全一致——需要逐项检查每个菜单在小游戏进行中是否仍然合理。保存功能是最明显的例子:
    Game.exportGameState()
    完全不知道正在进行的小游戏状态(参见下面的“常见陷阱”章节),因此,从小游戏内触发的保存操作要么需要在其中禁用/隐藏,要么小游戏必须先持久化自己的状态(
    pixi-vn-storage
    ),以确保保存仍然具有意义。不要直接接入游戏的原有菜单,并假定它们在小游戏内也能“直接可用”。

The
useMinigame
lifecycle hook

useMinigame
生命周期 hook

Every official template ships a
useMinigame
hook (e.g.
src/lib/hooks/minigame-hooks.ts
) that handles the repetitive part of wiring a minigame into a layer: creating the layer once, running an optional async
onStart
before the game begins, calling back into your
game(layer)
function, and cleaning the layer up (plus your
onExit
) when the screen unmounts.
ts
import { CANVAS_MINIGAME_LAYER_NAME } from "@/constants";
import { canvas, type Layer } from "@drincs/pixi-vn";
import { Container } from "pixi.js";
import { useEffect, useRef } from "react";

export function useMinigame(
  game: (layer: Layer) => void,
  props?: {
    onStart?: () => Promise<void>;
    onExit?: (layer: Layer) => void;
  },
) {
  const loading = useRef(false);

  useEffect(() => {
    loading.current = true;
    const layer = canvas.layers.add(CANVAS_MINIGAME_LAYER_NAME, new Container());
    if (!layer) {
      console.error("Failed to create UI layer for minigame");
      return;
    }

    let cancelled = false;
    (props?.onStart ?? (async () => {}))().then(() => {
      if (cancelled) return;
      loading.current = false;
      game(layer);
    });

    return () => {
      cancelled = true;
      canvas.layers.remove(CANVAS_MINIGAME_LAYER_NAME);
      props?.onExit?.(layer);
    };
  }, [game]);

  return { loading };
}
Usage inside the minigame's own route file:
tsx
import { Layer } from "@drincs/pixi-vn";
import { Graphics, Ticker } from "@drincs/pixi-vn/pixi.js";
import { createFileRoute } from "@tanstack/react-router";
import { useCallback, useMemo, useState } from "react";
import { useHotkey } from "@tanstack/react-hotkeys";
import useMinigame from "@/lib/hooks/minigame-hooks";

export const Route = createFileRoute("/minigame/example")({
  component: MiniGame,
});

function MiniGame() {
  const [score, setScore] = useState(0);
  const [gameOver, setGameOver] = useState(false);

  const ticker = useMemo(() => {
    const ticker = new Ticker();
    ticker.add(({ deltaMS }) => {
      // update game logic; call setGameOver(true) + ticker.stop() to end
    });
    return ticker;
  }, []);

  useHotkey(
    "ArrowUp",
    () => {
      /* handle input */
    },
    { enabled: !gameOver },
  );

  const game = useCallback(
    (layer: Layer) => {
      ticker.start();
    },
    [ticker], // must NOT change during the game or it restarts
  );

  const options = useMemo(
    () => ({
      onExit() {
        ticker.stop();
        ticker.destroy();
      },
    }),
    [ticker], // must NOT change during the game or it restarts
  );

  useMinigame(game, options);

  return (
    <>{/* score HUD, game-over overlay, control buttons — regular UI */}</>
  );
}
  • game(layer)
    is where you build the actual PixiJS content (
    Graphics
    ,
    Sprite
    ,
    Text
    , a
    Ticker
    ) and add it to the given
    layer
    . It runs once, after
    onStart
    resolves.
  • onExit(layer)
    is where you tear down anything
    game
    started — stop and
    destroy()
    any
    Ticker
    , remove event listeners.
    useMinigame
    already removes the layer itself.
  • game
    and the
    options
    object (specifically its
    onExit
    ) must keep a stable identity
    across re-renders (
    useCallback
    /
    useMemo
    with the right deps) — since they're the effect's dependency, changing identity mid-game tears down and restarts the whole minigame.
每个官方模板都会提供一个
useMinigame
hook(例如
src/lib/hooks/minigame-hooks.ts
),用于处理将小游戏接入图层的重复工作:只创建一次图层、在游戏开始前运行可选的异步
onStart
、回调你的
game(layer)
函数,并在屏幕卸载时清理图层以及调用
onExit
。
ts
import { CANVAS_MINIGAME_LAYER_NAME } from "@/constants";
import { canvas, type Layer } from "@drincs/pixi-vn";
import { Container } from "pixi.js";
import { useEffect, useRef } from "react";

export function useMinigame(
  game: (layer: Layer) => void,
  props?: {
    onStart?: () => Promise<void>;
    onExit?: (layer: Layer) => void;
  },
) {
  const loading = useRef(false);

  useEffect(() => {
    loading.current = true;
    const layer = canvas.layers.add(CANVAS_MINIGAME_LAYER_NAME, new Container());
    if (!layer) {
      console.error("Failed to create UI layer for minigame");
      return;
    }

    let cancelled = false;
    (props?.onStart ?? (async () => {})()).then(() => {
      if (cancelled) return;
      loading.current = false;
      game(layer);
    });

    return () => {
      cancelled = true;
      canvas.layers.remove(CANVAS_MINIGAME_LAYER_NAME);
      props?.onExit?.(layer);
    };
  }, [game]);

  return { loading };
}
在小游戏自己的路由文件中的用法:
tsx
import { Layer } from "@drincs/pixi-vn";
import { Graphics, Ticker } from "@drincs/pixi-vn/pixi.js";
import { createFileRoute } from "@tanstack/react-router";
import { useCallback, useMemo, useState } from "react";
import { useHotkey } from "@tanstack/react-hotkeys";
import useMinigame from "@/lib/hooks/minigame-hooks";

export const Route = createFileRoute("/minigame/example")({
  component: MiniGame,
});

function MiniGame() {
  const [score, setScore] = useState(0);
  const [gameOver, setGameOver] = useState(false);

  const ticker = useMemo(() => {
    const ticker = new Ticker();
    ticker.add(({ deltaMS }) => {
      // update game logic; call setGameOver(true) + ticker.stop() to end
    });
    return ticker;
  }, []);

  useHotkey(
    "ArrowUp",
    () => {
      /* handle input */
    },
    { enabled: !gameOver },
  );

  const game = useCallback(
    (layer: Layer) => {
      ticker.start();
    },
    [ticker], // must NOT change during the game or it restarts
  );

  const options = useMemo(
    () => ({
      onExit() {
        ticker.stop();
        ticker.destroy();
      },
    }),
    [ticker], // must NOT change during the game or it restarts
  );

  useMinigame(game, options);

  return (
    <>{/* score HUD, game-over overlay, control buttons — regular UI */}</>
  );
}
  • game(layer)
    是构建实际 PixiJS 内容(
    Graphics
    、
    Sprite
    、
    Text
    、
    Ticker
    )并将其添加到指定
    layer
    的位置。它会在
    onStart
    resolve 后运行一次。
  • onExit(layer)
    用于销毁
    game
    启动的所有内容——停止并
    destroy()
    任何
    Ticker
    、移除事件监听器。
    useMinigame
    已经负责移除图层本身。
  • game
    和
    options
    对象(尤其是其中的
    onExit
    )必须在重新渲染期间保持引用身份稳定
    ——使用带有正确依赖项的
    useCallback
    /
    useMemo
    ——因为它们是 effect 的依赖项,在游戏过程中改变引用身份会销毁并重启整个小游戏。

Controls and rendering

控制与渲染

  • Use hotkeys for keyboard input (see
    pixi-vn-ui
    /hotkeys) rather than raw
    window.addEventListener("keydown", ...)
    , and gate them with
    enabled: !gameOver
    so input stops mounting a listener that outlives the round.
  • Build the actual gameplay visuals with PixiJS directly — either the
    pixi.js
    package or the
    @drincs/pixi-vn/pixi.js
    re-export — using
    Graphics
    /
    Sprite
    /
    Text
    and a
    Ticker
    for the game loop, same primitives as
    pixi-vn-canvas
    but on the dedicated minigame layer instead of
    gameLayer
    .
  • On-screen touch controls, score displays, and game-over messages should still be plain UI components (buttons, divs) layered over the canvas with
    pointerEvents: "auto"
    where they need to be clickable — see
    pixi-vn-ui
    's "Enabling interaction" section.
  • 使用热键处理键盘输入(参见
    pixi-vn-ui
    /hotkeys),而不是直接使用
    window.addEventListener("keydown", ...)
    ,并通过
    enabled: !gameOver
    对其进行门控,确保回合结束后输入监听器不会继续生效。
  • 使用 PixiJS 直接构建实际的游戏视觉内容——可以选择
    pixi.js
    包,或使用
    @drincs/pixi-vn/pixi.js
    重新导出的 API——通过
    Graphics
    /
    Sprite
    /
    Text
    和
    Ticker
    实现游戏循环。其基础机制与
    pixi-vn-canvas
    相同,只是应使用专用的小游戏图层,而不是
    gameLayer
    。
  • 屏幕触控控制、分数显示和游戏结束消息仍应使用普通 UI 组件(按钮、div),叠加在画布上;需要点击的位置应设置
    pointerEvents: "auto"
    ——参见
    pixi-vn-ui
    的“启用交互”章节。

Gotchas

常见陷阱

  • Adding game content to
    canvas.gameLayer
    instead of a dedicated layer
    makes it save-able and mixes it with narrative elements — use
    canvas.layers.add
    for the minigame instead (
    pixi-vn-canvas
    has the full
    gameLayer
    vs. layer distinction).
  • An unstable
    game
    /
    onExit
    reference restarts the minigame on every render
    — see the
    useCallback
    /
    useMemo
    note above; this is the single most common bug when porting the pattern.
  • Nothing about minigame progress is captured by
    Game.exportGameState()
    — a mid-round save/ reload will silently drop it unless you persist state yourself through game storage (
    pixi-vn-storage
    /
    pixi-vn-saves
    ).
  • Forgetting to stop/destroy the
    Ticker
    in
    onExit
    leaves the game loop running (and potentially erroring against removed objects) after the player navigates away.
  • 将游戏内容添加到
    canvas.gameLayer
    而不是专用图层
    会使其成为可保存状态,并与叙事元素混在一起——小游戏应使用
    canvas.layers.add
    (
    pixi-vn-canvas
    详细介绍了
    gameLayer
    与其他图层的区别)。
  • 不稳定的
    game
    /
    onExit
    引用会在每次渲染时重启小游戏
    ——参见上方关于
    useCallback
    /
    useMemo
    的说明;这是迁移该模式时最常见的错误。
  • Game.exportGameState()
    不会捕获任何小游戏进度
    ——如果未通过 game storage(
    pixi-vn-storage
    /
    pixi-vn-saves
    )自行持久化状态,那么在回合中途保存或重新加载会静默丢失进度。
  • **忘记在
    onExit
    中停止/销毁
    Ticker
    **会导致游戏循环在玩家离开后继续运行,并可能因访问已移除的对象而报错。

Related skills

相关 skill

  • pixi-vn-canvas: the PixiJS rendering primitives (
    Graphics
    ,
    Sprite
    ,
    Text
    , tickers,
    canvas.animate
    ) a minigame is built from, and the
    gameLayer
    /layer distinction.
  • pixi-vn-ui:
    canvas.layers.add
    /
    htmlLayers.add
    , hotkeys, and navigating between UI screens/routes — how the minigame's HUD and route linkage actually work.
  • pixi-vn-storage: persisting minigame progress (score, board state) in game storage if it needs to survive a save/reload.
  • pixi-vn-saves: confirms
    Game.exportGameState()
    has no built-in notion of in-progress minigame state.
  • pixi-vn-canvas:构建小游戏所用的 PixiJS 渲染基础机制(
    Graphics
    、
    Sprite
    、
    Text
    、
    Ticker
    、
    canvas.animate
    ),以及
    gameLayer
    /图层之间的区别。
  • pixi-vn-ui:
    canvas.layers.add
    /
    htmlLayers.add
    、热键,以及在 UI 页面/路由之间导航——小游戏 HUD 和路由关联的实际工作方式。
  • pixi-vn-storage:如果小游戏进度需要在保存/重新加载后仍然存在,可通过 game storage 持久化小游戏进度(分数、棋盘状态)。
  • pixi-vn-saves:确认
    Game.exportGameState()
    没有内置的“正在进行的小游戏状态”概念。