chore: share game-studio hermes plugin

This commit is contained in:
2026-05-11 12:00:45 +08:00
parent d23cf3807d
commit 81f57ea5ce
43 changed files with 2230 additions and 1 deletions
+40 -1
View File
@@ -24,9 +24,47 @@
│ ├─ pitfalls.md # 踩坑与排障记录
│ └─ handoff-template.md # 任务交接模板
├─ plans/ # 阶段性计划与实施方案
─ skills/ # 未来可沉淀的仓库级 Hermes skills
─ skills/ # 仓库级 Hermes skills
└─ plugins/ # 仓库级 Hermes plugins(需显式启用项目 plugin
```
## 仓库级 Plugins
本仓库可共享的 Hermes plugin 放在 `.hermes/plugins/<plugin-name>/`。当前已包含:
- `.hermes/plugins/game-studio/`:浏览器游戏设计、原型、2D/3D 技术栈、素材管线与 playtest 相关工作流。
Hermes 的项目级 plugin 默认不会自动加载。团队成员拉取仓库后,如需使用本仓库内 plugin,请在仓库根目录启动 Hermes 前设置:
```bash
export HERMES_ENABLE_PROJECT_PLUGINS=1
```
然后确认当前 Hermes 配置的 `plugins.enabled` 中包含 `game-studio`。如果成员本机尚未启用过该 plugin,当前 Hermes 的 `hermes plugins enable` 只识别用户级或内置 plugin,可能不会识别项目级 plugin;可用以下命令写入个人配置:
```bash
python - <<'PY'
from hermes_cli.config import load_config, save_config
config = load_config()
plugins = config.setdefault('plugins', {})
enabled = set(plugins.get('enabled') or [])
disabled = set(plugins.get('disabled') or [])
enabled.add('game-studio')
disabled.discard('game-studio')
plugins['enabled'] = sorted(enabled)
plugins['disabled'] = sorted(disabled)
save_config(config)
PY
```
启用后重新进入一个新 Hermes 会话。`hermes plugins list` 当前主要展示内置和用户级 plugin,未必列出项目级 plugin;如需验证项目级扫描,可在仓库根目录运行:
```bash
HERMES_ENABLE_PROJECT_PLUGINS=1 HERMES_PLUGINS_DEBUG=1 hermes chat -q "请读取 game-studio:game-studio skill 并概括它的用途"
```
该 plugin 注册的是带命名空间的 plugin skills,可用类似 `game-studio:phaser-2d-game` 的名称显式加载。
## 推荐给 Hermes 的启动提示
在本仓库中开始复杂任务时,可以先对 Hermes 说:
@@ -51,3 +89,4 @@
- 大段临时聊天记录
- 尚未确认的一次性猜测
- 构建产物、日志、缓存、数据库 dump
@@ -0,0 +1,46 @@
{
"name": "game-studio",
"version": "0.1.0",
"description": "Design, prototype, and ship browser games with guided 2D and 3D workflows, asset pipelines, and playtesting support.",
"author": {
"name": "OpenAI",
"email": "support@openai.com",
"url": "https://openai.com/"
},
"homepage": "https://openai.com/",
"repository": "https://github.com/openai/plugins",
"license": "MIT",
"keywords": [
"games",
"phaser",
"threejs",
"react-three-fiber",
"gltf",
"rapier",
"webgl",
"sprites",
"playtest"
],
"skills": "./skills/",
"interface": {
"displayName": "Game Studio",
"shortDescription": "Design, prototype, and ship browser games",
"longDescription": "Plan, prototype, and build browser games with guided workflows for gameplay systems, UI, asset pipelines, and playtesting across 2D and 3D projects.",
"developerName": "OpenAI",
"category": "Coding",
"capabilities": [
"Interactive",
"Write"
],
"websiteURL": "https://openai.com/",
"privacyPolicyURL": "https://openai.com/policies/privacy-policy/",
"termsOfServiceURL": "https://openai.com/policies/terms-of-use/",
"defaultPrompt": [
"Design a browser game and plan the core loop"
],
"brandColor": "#0F766E",
"composerIcon": "./assets/game-studio.svg",
"logo": "./assets/app-icon.png",
"screenshots": []
}
}
+38
View File
@@ -0,0 +1,38 @@
"""Hermes wrapper for the OpenAI Codex Game Studio plugin.
This plugin was imported from a Codex curated plugin cache. It exposes the
plugin's bundled SKILL.md files as Hermes plugin skills using qualified names
like `game-studio:phaser-2d-game`.
"""
from __future__ import annotations
from pathlib import Path
def _read_description(skill_md: Path) -> str:
try:
text = skill_md.read_text(encoding="utf-8")[:4000]
except Exception:
return ""
if text.startswith("---"):
end = text.find("\n---", 3)
if end != -1:
frontmatter = text[3:end]
for line in frontmatter.splitlines():
if line.strip().startswith("description:"):
return line.split(":", 1)[1].strip().strip("\"'")
return ""
def register(ctx) -> None:
root = Path(__file__).resolve().parent
skills_root = root / "skills"
if not skills_root.exists():
return
for skill_md in sorted(skills_root.glob("*/SKILL.md")):
ctx.register_skill(
name=skill_md.parent.name,
path=skill_md,
description=_read_description(skill_md),
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
<title id="title">Game Studio</title>
<desc id="desc">A stylized browser game plugin icon with a viewport frame, a d-pad, and layered tiles.</desc>
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#134E4A"/>
<stop offset="100%" stop-color="#0F766E"/>
</linearGradient>
<linearGradient id="screen" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#CCFBF1"/>
<stop offset="100%" stop-color="#5EEAD4"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="52" fill="url(#bg)"/>
<rect x="40" y="46" width="176" height="124" rx="20" fill="#062F2D" stroke="#7DD3C7" stroke-width="8"/>
<rect x="58" y="62" width="140" height="92" rx="12" fill="url(#screen)"/>
<path d="M76 132h28l14-18 18 20 24-30 18 18v18H76z" fill="#0F766E" opacity="0.9"/>
<rect x="62" y="178" width="50" height="50" rx="18" fill="#0B2F2D" stroke="#7DD3C7" stroke-width="6"/>
<rect x="144" y="178" width="50" height="50" rx="18" fill="#0B2F2D" stroke="#7DD3C7" stroke-width="6"/>
<rect x="81" y="189" width="12" height="28" rx="4" fill="#CCFBF1"/>
<rect x="73" y="197" width="28" height="12" rx="4" fill="#CCFBF1"/>
<circle cx="160" cy="203" r="8" fill="#FDE68A"/>
<circle cx="178" cy="203" r="8" fill="#FCA5A5"/>
<circle cx="169" cy="192" r="8" fill="#BFDBFE"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+6
View File
@@ -0,0 +1,6 @@
name: game-studio
version: 0.1.0
description: Design, prototype, and ship browser games with guided 2D and 3D workflows,
asset pipelines, and playtesting support.
author: OpenAI
kind: standalone
@@ -0,0 +1,50 @@
# Alternative 3D Engines
This plugin defaults to Three.js and React Three Fiber for code generation. Babylon.js and PlayCanvas still matter, but they are reference-only alternatives in the current plugin shape.
## Babylon.js
Useful sources:
- [Babylon.js home](https://babylonjs.com/)
- [Engine specifications](https://www.babylonjs.com/specifications/)
- [Babylon.js Editor](https://editor.babylonjs.com/)
Choose Babylon.js when:
- the user explicitly wants Babylon.js
- the team wants a more engine-heavy stack with scene, material, viewer, and editor tooling built around one ecosystem
- WebGPU, Havok, node-based rendering or material tooling, or Babylon-specific runtime features are part of the reason for the choice
What Babylon.js is good at:
- full-engine 3D workflows
- strong built-in tooling and editor surfaces
- WebGL and WebGPU support inside one ecosystem
- integrated viewer and inspection-oriented workflows
## PlayCanvas
Useful sources:
- [PlayCanvas graphics overview](https://developer.playcanvas.com/user-manual/graphics/)
- [Supported formats](https://developer.playcanvas.com/user-manual/assets/supported-formats/)
- [PlayCanvas React GLTF API](https://developer.playcanvas.com/user-manual/react/api/gltf/)
- [PlayCanvas Web Components](https://developer.playcanvas.com/user-manual/web-components/)
Choose PlayCanvas when:
- the user explicitly wants PlayCanvas
- the team prefers an editor-centric browser engine workflow
- GLB import, runtime tooling, React bindings, or web-component-based embedding are central to the project
What PlayCanvas is good at:
- editor and engine working together
- GLB-centric browser asset workflows
- strong web embedding patterns
- WebGL and WebGPU support with browser-focused runtime tooling
## Default recommendation
If the user has not already chosen Babylon.js or PlayCanvas, prefer Three.js or React Three Fiber in this plugin because they give the best balance of portability, ecosystem depth, and predictable code generation across normal browser-game repos.
@@ -0,0 +1,53 @@
# Engine Selection
Use this table to choose the default implementation path for browser games in this plugin.
## Default choices
- Choose Phaser for 2D games unless the user explicitly asks for another stack.
- Choose vanilla Three.js for explicit 3D or WebGL-first experiences in plain TypeScript or Vite apps.
- Choose React Three Fiber when the 3D scene is part of a React application and shared app state or declarative composition matters.
- Choose raw WebGL only when engine abstractions are the problem, not because WebGL sounds more advanced.
- Treat Babylon.js and PlayCanvas as alternative ecosystems, not the default path in this plugin.
## Phaser is the best fit when
- the game is sprite- or tile-based
- the game is top-down, side-view, or grid tactics
- you need camera, sprite, and scene primitives quickly
- the UI will mostly live in DOM overlays
- the game loop is gameplay-first rather than renderer-first
## Three.js is the best fit when
- the game is genuinely 3D
- camera movement and depth are central to play
- materials, lighting, or scene composition matter more than sprite tooling
- the user explicitly asks for Three.js or WebGL-based 3D work
- the team wants direct control over scene setup, loaders, physics integration, and the game loop
## React Three Fiber is the best fit when
- the project already lives in React
- the 3D scene needs to share app state with the rest of the product
- declarative scene composition is more valuable than a fully imperative loop
- the team benefits from pmndrs tooling such as Drei, React Postprocessing, or `@react-three/rapier`
## Babylon.js or PlayCanvas are the best fit when
- the user explicitly asks for those engines
- the team already has engine-specific tooling or editor workflows in those ecosystems
- the project wants engine-heavy runtime features, editor-first workflows, or platform-specific tooling that Three.js does not provide by default
## Raw WebGL is the best fit when
- the project is shader-heavy
- you need a custom renderer or post-processing pipeline
- the user explicitly wants low-level rendering control
## Avoid these mismatches
- Do not choose a 3D stack for a normal 2D tactics or platformer game.
- Do not choose raw WebGL for a game that mostly needs engine conveniences.
- Do not force HUD and menus into canvas or WebGL when DOM would be clearer.
- Do not default to Babylon.js or PlayCanvas when the user mainly wants portable TypeScript code generation across browser-game repos.
@@ -0,0 +1,97 @@
# Frontend Prompts
Use these prompt shapes to keep browser-game UI intentional instead of generic.
## Prompt ingredients
- game genre and fantasy
- camera or viewpoint
- player verbs
- HUD zones
- menu surfaces
- motion tone
- desktop and mobile expectations
- playfield protection and disclosure strategy
- anti-patterns to avoid
## HUD implementation prompt
```text
Design and implement the HUD for a browser game.
Game fantasy: <genre and world>
Viewpoint: <top-down, side-view, tactical grid, third-person, first-person>
Primary verbs: <attack, move, cast, build, dodge, inspect>
HUD zones: <top status, bottom command bar, side objectives, modal panels>
Tone: <ornate, rugged, clean sci-fi, painterly, arcade>
Motion: <restrained, snappy, dramatic only on important state changes>
Platforms: desktop and mobile
Constraints: readable over active gameplay, DOM-based overlays, CSS variables, no generic dashboard look
Playfield protection: keep the central play area clear during normal play, prefer one primary persistent HUD cluster, and move long-form notes or controls behind menus
Avoid: flat admin UI, default font stack, cluttered overlays, constant micro-animation, equal-weight cards around every edge, broad always-on panels that cover the world
```
## Menu implementation prompt
```text
Build the shell UI for a browser game with the following surfaces:
- title screen
- pause menu
- settings panel
- game-over or victory screen
Keep the menus visually tied to the game world, not to a SaaS app aesthetic. Use strong hierarchy, intentional typography, meaningful motion, and responsive layout.
```
## Low-chrome 3D starter prompt
```text
Design the initial playable HUD for a browser 3D game.
Goal: the first screen should feel playable in under 3 seconds, not like a dashboard.
Camera mode: <third-person, first-person, orbit, rail>
Primary verbs: <move, inspect, interact, attack, build>
Persistent UI budget:
- one compact objective chip or status cluster
- one optional small secondary surface
- one transient controls or interaction hint
Interaction rules:
- keep the center of the playfield clear
- keep the lower-middle playfield mostly clear during normal play
- lore, notes, quest details, and long control lists live behind a drawer, pause menu, or toggle
- modal and pause states must gate camera input correctly
Avoid:
- giant title cards over live gameplay
- field notes, controls, and objectives all open at once
- equally weighted glass panels in every corner
- full-screen overlay chrome during normal movement
```
## 3D overlay prompt
```text
Design and implement the HUD and menu overlays for a browser 3D game.
Engine context: <vanilla Three.js or React Three Fiber>
Camera mode: <third-person, first-person, orbit, rail>
Primary verbs: <move, inspect, interact, attack, build>
Overlay surfaces: <reticle, quest log, inventory, pause menu, settings>
Interaction constraints:
- DOM overlays, not in-scene UI by default
- modal and menu states must suspend or gate camera input correctly
- keyboard and pointer states must be explicit
- reduced-motion support for non-essential transitions
- keep the center of the screen clear during normal play
- keep the lower-middle playfield mostly clear during normal play
- start with one compact objective surface and transient hints rather than multiple permanent cards
- secondary content such as notes, lore, and full control references should be collapsed by default
Avoid:
- dashboard UI
- cluttered full-screen overlays
- boxed panels around every edge of the viewport
- full-width top-and-bottom panel stacks
- permanent text-heavy cards competing with the scene
- camera movement continuing under active menus
```
@@ -0,0 +1,43 @@
# GLB Loading Starter
Use this as the canonical minimal pattern for loading shipped 3D content.
## Vanilla Three.js
```ts
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
const draco = new DRACOLoader();
draco.setDecoderPath("/draco/");
const gltfLoader = new GLTFLoader();
gltfLoader.setDRACOLoader(draco);
gltfLoader.load("/assets/hero.glb", (gltf) => {
const root = gltf.scene;
root.traverse((node) => {
if ("castShadow" in node) {
node.castShadow = true;
node.receiveShadow = true;
}
});
scene.add(root);
});
```
## React Three Fiber
```tsx
import { useGLTF } from "@react-three/drei";
function HeroModel() {
const gltf = useGLTF("/assets/hero.glb");
return <primitive object={gltf.scene} />;
}
```
## Notes
- Default shipping format is GLB or glTF 2.0.
- Keep optimization upstream in the asset pipeline; loader code should stay boring.
@@ -0,0 +1,56 @@
# Phaser Architecture
This is the default 2D structure for the plugin.
## Recommended module split
```text
src/
game/
simulation/
state.ts
systems/
rules/
content/
encounters/
items/
maps/
input/
actions.ts
bindings.ts
assets/
manifest.ts
phaser/
boot/
scenes/
BootScene.ts
MenuScene.ts
BattleScene.ts
view/
sprites/
fx/
camera/
adapters/
sceneBridge.ts
ui/
hud/
menus/
overlays/
```
## Responsibilities
- `simulation/`: source of truth for rules and saveable state
- `content/`: authored data and encounter configuration
- `input/`: action map and physical control bindings
- `assets/`: stable manifest keys and asset metadata
- `phaser/scenes/`: scene orchestration, not game rules
- `phaser/view/`: render and effect helpers
- `ui/`: DOM HUD, menus, and narrative panels
## Rules
- Phaser scenes read from and write to the simulation through a defined bridge.
- Game state changes should not depend on sprite or tween lifetime.
- Camera behavior should be isolated from combat or movement rules.
- Use DOM for dense text and settings surfaces.
@@ -0,0 +1,51 @@
# Playtest Checklist
Use this checklist for browser-game QA.
## Universal checks
- Does the game boot into a useful first state?
- Are the main verbs obvious and responsive?
- Does the HUD remain readable over gameplay?
- Does the first playable screen prioritize play over dashboard chrome?
- Does the central playfield stay mostly clear during normal play?
- Do pause, failure, and recovery states work?
- Does the game survive viewport changes?
## 2D checks
- sprite baseline consistency
- hit, hurt, and attack timing
- command menu focus and input state
- tile or platform readability
- particle or camera effects obscuring gameplay
## 3D checks
- camera control stability
- camera and menu-state handoff
- depth readability
- persistent overlay weight versus scene readability
- secondary notes, controls, and quest details collapsed by default
- resize and aspect-ratio handling
- renderer fallback or context-loss handling
- material and lighting stability across states
- GLB asset and texture streaming behavior
- collision proxy alignment
- GPU bottlenecks isolated with capture tools when needed
## Browser checks
- desktop and mobile viewports
- input modality differences
- reduced-motion behavior
- pause behavior when focus changes
- pointer-lock and camera-input release when overlays open
- transient onboarding hints dismiss or fade once the player is moving
## Reporting
- Capture screenshots for visual findings.
- Put findings in severity order.
- Include reproduction steps.
- Call out whether the likely owner is simulation, renderer, frontend, or asset pipeline.
@@ -0,0 +1,42 @@
# Rapier Integration Starter
Use this as the smallest canonical pattern for adding physics without letting it take over the whole runtime.
## Vanilla Three.js
```ts
import RAPIER from "@dimforge/rapier3d-compat";
await RAPIER.init();
const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
const body = world.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(0, 2, 0));
world.createCollider(RAPIER.ColliderDesc.cuboid(0.5, 0.5, 0.5), body);
renderer.setAnimationLoop(() => {
world.step();
const p = body.translation();
mesh.position.set(p.x, p.y, p.z);
renderer.render(scene, camera);
});
```
## React Three Fiber
```tsx
import { Physics, RigidBody } from "@react-three/rapier";
<Physics gravity={[0, -9.81, 0]}>
<RigidBody colliders="cuboid">
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#3dd9b8" />
</mesh>
</RigidBody>
</Physics>;
```
## Notes
- Keep physics state synchronized through an explicit bridge.
- Do not bury gameplay rules inside render or physics callbacks.
@@ -0,0 +1,42 @@
# React Three Fiber Stack
This is the default React-native 3D stack for the plugin.
## Primary components
- [React Three Fiber](https://r3f.docs.pmnd.rs/getting-started/introduction) for declarative Three.js rendering in React.
- [Drei](https://drei.docs.pmnd.rs/controls/introduction) for controls, loaders, helpers, environments, and common scene utilities.
- [React Postprocessing](https://react-postprocessing.docs.pmnd.rs/introduction) for effect composition in React-hosted scenes.
- [React Three Rapier](https://pmndrs.github.io/react-three-rapier/) for physics integration.
- [React Three A11y](https://a11y.docs.pmnd.rs/introduction) when scene interaction benefits from accessibility-aware patterns.
- [glTF Transform](https://gltf-transform.dev/) and the [glTF 2.0 specification](https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html) for shipped assets.
## Default stack choices
- Runtime: `@react-three/fiber` + `three`
- Helper ecosystem: `@react-three/drei`
- Physics: `@react-three/rapier`
- Effects: `@react-three/postprocessing`
- Accessibility: `@react-three/a11y` when appropriate
- Assets: GLB or glTF 2.0
## Choose this stack when
- the 3D scene lives inside a React app
- the UI shell, settings, or product flow already uses React
- the team benefits from declarative scene composition
- the scene must share app state with non-canvas UI
## Avoid this stack when
- the project wants a cleaner imperative loop with minimal React coordination
- the whole game runtime would be easier to reason about in plain TypeScript
## Companion references
- `threejs-stack.md`
- `react-three-fiber-starter.md`
- `gltf-loading-starter.md`
- `rapier-integration-starter.md`
- `web-3d-asset-pipeline.md`
- `webgl-debugging-and-performance.md`
@@ -0,0 +1,51 @@
# React Three Fiber Starter
Use this as the smallest canonical starting point for a React-hosted 3D scene.
## Files
```text
src/
App.tsx
```
## `src/App.tsx`
```tsx
import { Canvas } from "@react-three/fiber";
function Spinner() {
return (
<mesh rotation={[0.4, 0.6, 0]}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#3dd9b8" />
</mesh>
);
}
export default function App() {
return (
<div className="app-shell">
<Canvas camera={{ position: [0, 1.5, 4], fov: 60 }}>
<color attach="background" args={["#101418"]} />
<ambientLight intensity={0.7} />
<directionalLight position={[4, 6, 3]} intensity={1.2} />
<Spinner />
</Canvas>
<div className="hud">
<div className="objective-chip">Reach the lantern bridge.</div>
<div className="hint-pill">WASD to move. Hold mouse to look.</div>
</div>
</div>
);
}
```
## Notes
- Start here when the 3D scene lives inside an existing React app.
- Keep the initial HUD sparse. One compact objective surface and one transient hint is usually enough for a first playable scaffold.
- Put lore, notes, map, and settings behind drawers or modals instead of opening them all by default.
- Add GLB loading with `gltf-loading-starter.md`.
- Add physics with `rapier-integration-starter.md`.
- Use `three-hud-layout-patterns.md` for low-chrome 3D overlay defaults.
@@ -0,0 +1,57 @@
# Sprite Pipeline
This is the default 2D animation workflow for the plugin.
## Principles
- Start from one approved in-game frame.
- Generate the animation as one strip, not isolated frames.
- Normalize the whole strip with one shared scale.
- Use one shared anchor, typically bottom-center.
- Preview before approving the asset.
## Why this works
- The approved seed frame preserves identity.
- Strip-first generation reduces frame-to-frame drift.
- Shared-scale normalization prevents one tall pose from making the character feel smaller.
- Locking frame 01 back to the shipped sprite preserves continuity for idle-to-action transitions.
## Prompt template
```text
Intended use: candidate production spritesheet for a 2D browser game animation review.
Edit the provided transparent reference canvas into a single horizontal <N>-frame spritesheet.
The existing sprite in the leftmost slot is the anchor frame and must remain the same character:
- same facing direction
- same silhouette family
- same palette family
- same proportions
- same readable face or key features
- same outfit details
Composition:
- transparent canvas
- exactly one row of <N> equal frame slots
- no extra characters
- no labels
- no scenery
- no poster layout
Action:
- describe the specific animation beat from frame 1 through frame N
Style:
- authentic pixel-art production asset
- crisp pixel clusters
- restrained palette
- not concept art
```
## Normalization notes
- Use the union of detected sprite bounds per slot.
- Compute one scale from the largest detected frame and anchor.
- Bottom-align frames into the target canvas.
- Reuse the exact shipped frame for frame 01 when `--lock-frame1` is appropriate.
@@ -0,0 +1,61 @@
# 3D HUD Layout Patterns
Use these defaults for initial 3D browser-game scaffolds. The first screen should be playable before it is informational.
## Layout Budget
- Keep the center of the screen clear during normal play.
- On desktop, prefer one primary persistent cluster and one small secondary cluster.
- On mobile, prefer one compact persistent cluster and transient prompts.
- Secondary information belongs in drawers, toggles, pause menus, or contextual popovers.
## Good Default Patterns
### Objective chip
- One short objective in a compact top-corner chip.
- One optional sublabel for location or mode.
- No giant hero banner over the live scene.
### Contextual interaction prompt
- Bottom-center or lower-corner pill.
- Appears only near interactables or during onboarding.
- Dismisses after first use or fades once the player is moving confidently.
### Small status strip
- Health, energy, party count, or beacon progress in a narrow edge-aligned strip.
- Use icons, short labels, and compact meters instead of stacked cards.
### Collapsible journal or quest log
- Closed by default.
- Opened by a hotkey, button, or pause state.
- Holds longer prose, lore, map notes, and multi-step objective details.
### Pause and settings modal
- Explicit modal state.
- Suspends pointer-lock, drag-look, or camera input while active.
## Anti-Patterns
- four to six glass cards permanently framing the viewport
- large lore or field-notes panels open during normal movement
- controls lists permanently pinned to the screen
- symmetric dashboard composition that competes with the scene
- oversized title panels staying visible after the first second of play
## Example UI Budget
- top-left: objective chip
- top-right: compact status strip
- bottom-center: transient interaction or controls hint
- pause menu or drawer: map, notes, inventory, settings
## Prompt Add-On
```text
Default to a low-chrome playable HUD. Keep the central playfield clear. Use one compact objective chip, one small status surface, and transient prompts. Put lore, field notes, full controls, and long checklists behind a drawer or pause menu. Avoid equal-weight boxed panels in every corner.
```
@@ -0,0 +1,61 @@
# Three WebGL Architecture
This is the default 3D structure for the plugin.
## Recommended module split
```text
src/
game/
simulation/
content/
input/
save/
render/
app/
createRenderer.ts
createScene.ts
createCamera.ts
createLoop.ts
loaders/
loadGltf.ts
loadEnvironment.ts
loadTextures.ts
objects/
materials/
lights/
post/
adapters/
renderBridge.ts
physics/
world.ts
colliders.ts
sync.ts
diagnostics/
debugFlags.ts
perf.ts
ui/
hud/
menus/
overlays/
```
## Responsibilities
- `simulation/`: rules, state, AI, progression, save data
- `render/app/`: renderer, scene, camera, resize, context lifecycle
- `render/loaders/`: GLTF, compression, texture, and environment loading
- `render/objects/`: scene graph construction and disposal
- `render/materials/`: material setup and shader boundaries
- `physics/`: Rapier world and simulation bridge
- `diagnostics/`: performance probes and GPU debugging hooks
- `ui/`: DOM HUD and menus
## Rules
- Scene graph objects are not the source of truth for game rules.
- Keep camera logic explicit and testable.
- Handle resize and context-loss as real browser concerns.
- Keep high-density UI in DOM even when the world is fully 3D.
- Treat GLB or glTF 2.0 as the default content format.
- Add physics and diagnostics as real subsystems, not temporary one-off utilities.
@@ -0,0 +1,41 @@
# Three.js Stack
This is the default non-React 3D runtime stack for the plugin.
## Primary components
- [Three.js documentation](https://threejs.org/docs/) for the core renderer, scene graph, materials, cameras, loaders, and examples.
- [Rapier JavaScript guide](https://rapier.rs/docs/user_guides/javascript/getting_started_js/) for physics integration.
- [glTF 2.0 specification](https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html) for the default shipping asset format.
- [glTF Transform](https://gltf-transform.dev/) for optimization, packaging, and compression workflows.
- [SpectorJS](https://spector.babylonjs.com/) for WebGL frame capture and GPU debugging.
## Default stack choices
- Runtime: `three`
- Tooling: TypeScript + Vite
- Assets: GLB or glTF 2.0
- Loaders: `GLTFLoader`, `DRACOLoader`, `KTX2Loader` when the asset pipeline requires them
- Physics: Rapier JS
- UI: DOM overlays, not in-scene UI by default
## Choose this stack when
- the project is not React-first
- the team wants direct control over the render loop
- scene composition, loader setup, or custom render behavior needs imperative structure
- the game code should feel engine-like without a React abstraction layer
## Avoid this stack when
- the surrounding app is already React-heavy and wants shared declarative state
- the project needs an editor-first engine workflow more than a portable TypeScript runtime
## Companion references
- `three-webgl-architecture.md`
- `threejs-vanilla-starter.md`
- `gltf-loading-starter.md`
- `rapier-integration-starter.md`
- `web-3d-asset-pipeline.md`
- `webgl-debugging-and-performance.md`
@@ -0,0 +1,58 @@
# Three.js Vanilla Starter
Use this as the smallest canonical starting point for a plain TypeScript or Vite Three.js app.
## Files
```text
src/
main.ts
```
## `src/main.ts`
```ts
import * as THREE from "three";
const scene = new THREE.Scene();
scene.background = new THREE.Color("#101418");
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 200);
camera.position.set(0, 1.5, 4);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 0.7));
const light = new THREE.DirectionalLight(0xffffff, 1.2);
light.position.set(4, 6, 3);
scene.add(light);
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: "#3dd9b8" }),
);
scene.add(mesh);
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
renderer.setAnimationLoop(() => {
mesh.rotation.y += 0.01;
renderer.render(scene, camera);
});
```
## Notes
- Start here for direct loop and renderer control.
- If the scaffold needs UI, start with one compact objective chip and one transient controls hint rather than multiple permanent cards.
- Keep notes, codex, maps, and settings behind on-demand surfaces. The starter scene should stay readable while moving the camera.
- Add GLB loading with `gltf-loading-starter.md`.
- Add physics sync with `rapier-integration-starter.md`.
- Use `three-hud-layout-patterns.md` for low-chrome 3D overlay defaults.
@@ -0,0 +1,47 @@
# Web 3D Asset Pipeline
This is the default 3D asset shipping guidance for the plugin.
## Primary sources
- [Blender glTF exporter manual](https://docs.blender.org/manual/en/latest/addons/import_export/scene_gltf2.html)
- [glTF 2.0 specification](https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html)
- [glTF Transform](https://gltf-transform.dev/)
- [PlayCanvas supported formats](https://developer.playcanvas.com/user-manual/assets/supported-formats/) for a good reference on why GLB is the recommended runtime format in browser engines
## Default output
- Ship GLB when possible.
- Use `.gltf` with external files only when the asset pipeline or delivery strategy genuinely needs that shape.
## Recommended workflow
1. Clean the source asset in the DCC tool.
2. Export to GLB or glTF 2.0.
3. Run glTF Transform for validation, pruning, deduplication, and size reduction.
4. Apply the chosen geometry and texture compression strategy.
5. Verify pivots, scale, collision assumptions, and hierarchy naming.
6. Test the asset in the runtime before treating it as final.
## Compression and optimization
- Use Draco or Meshopt deliberately, not both by default.
- Use KTX2 or BasisU when the runtime stack supports GPU-friendly compressed textures.
- Keep texture resolution aligned with actual on-screen use.
- Reuse materials and avoid unnecessary texture uniqueness.
## Runtime checks
- scale is consistent across assets
- pivots match gameplay expectations
- node names are stable
- collision proxy needs are handled
- animation clips and variants load correctly
- memory and load time are reasonable for the scene
## Starter patterns
- `threejs-vanilla-starter.md`
- `react-three-fiber-starter.md`
- `gltf-loading-starter.md`
- `rapier-integration-starter.md`
@@ -0,0 +1,36 @@
# WebGL Debugging and Performance
Use this reference when a browser 3D scene is visually wrong, unstable, or slower than expected.
## Primary tools
- [SpectorJS](https://spector.babylonjs.com/) for frame capture, pipeline inspection, draw-call review, and shader debugging.
- Browser performance tooling for main-thread work, asset decode stalls, and memory pressure.
- Engine-native debug views and stats surfaces where available.
## What to inspect first
- draw-call count
- shader compilation churn
- texture memory pressure
- geometry count and material count
- post-processing cost
- asset decode and streaming stalls
- WebGL context loss or fallback behavior
## Common causes of poor performance
- too many unique materials
- oversized textures
- heavy GLB assets loaded without optimization
- complex post-processing on top of an already expensive scene
- physics and render state fighting for ownership
- React and scene state updating each other too frequently in React-hosted 3D apps
## Debugging rules
- Capture first, then guess.
- Reduce the scene until the perf cliff becomes obvious.
- Disable post-processing before rewriting core scene code.
- Verify the asset pipeline before blaming the renderer.
- Treat context-loss handling as a browser requirement, not an edge case.
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Build a transparent edit canvas around a shipped seed sprite frame."""
from __future__ import annotations
import argparse
from pathlib import Path
try:
from PIL import Image
except ImportError as exc: # pragma: no cover
raise SystemExit(
"Pillow is required. Install it with `python3 -m pip install pillow`."
) from exc
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Upscale a seed sprite with nearest-neighbor sampling and place it into "
"the leftmost slot of a larger transparent edit canvas."
)
)
parser.add_argument("--seed", required=True, help="Path to the approved seed frame.")
parser.add_argument("--out", required=True, help="Path to the output PNG.")
parser.add_argument(
"--frames",
type=int,
default=4,
help="Number of horizontal frame slots to reserve. Default: 4.",
)
parser.add_argument(
"--slot-size",
type=int,
default=256,
help="Size of each square frame slot in pixels. Default: 256.",
)
parser.add_argument(
"--canvas-size",
type=int,
default=1024,
help="Size of the square transparent canvas in pixels. Default: 1024.",
)
return parser.parse_args()
def resize_seed(seed: Image.Image, slot_size: int) -> Image.Image:
max_dim = max(seed.size)
scale = slot_size / max_dim
if scale >= 1:
scale = max(1, int(scale))
width = max(1, int(round(seed.width * scale)))
height = max(1, int(round(seed.height * scale)))
return seed.resize((width, height), Image.Resampling.NEAREST)
def main() -> None:
args = parse_args()
if args.frames < 1:
raise SystemExit("--frames must be at least 1.")
if args.slot_size < 1 or args.canvas_size < 1:
raise SystemExit("--slot-size and --canvas-size must be positive.")
strip_width = args.frames * args.slot_size
if strip_width > args.canvas_size or args.slot_size > args.canvas_size:
raise SystemExit("Frame slots do not fit inside the requested canvas size.")
seed = Image.open(args.seed).convert("RGBA")
seed = resize_seed(seed, args.slot_size)
canvas = Image.new("RGBA", (args.canvas_size, args.canvas_size), (0, 0, 0, 0))
strip_left = (args.canvas_size - strip_width) // 2
strip_top = (args.canvas_size - args.slot_size) // 2
slot_left = strip_left
slot_top = strip_top
paste_x = slot_left + (args.slot_size - seed.width) // 2
paste_y = slot_top + (args.slot_size - seed.height) // 2
canvas.alpha_composite(seed, (paste_x, paste_y))
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
canvas.save(out_path)
if __name__ == "__main__":
main()
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Normalize a raw animation strip into fixed-size transparent frames."""
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Iterable
try:
from PIL import Image
except ImportError as exc: # pragma: no cover
raise SystemExit(
"Pillow is required. Install it with `python3 -m pip install pillow`."
) from exc
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Extract one horizontal strip into fixed-size frames using a shared "
"global scale and bottom-center alignment."
)
)
parser.add_argument("--input", required=True, help="Path to the raw strip image.")
parser.add_argument("--out-dir", required=True, help="Output directory for frames.")
parser.add_argument(
"--frames",
type=int,
required=True,
help="Number of horizontal frames in the strip.",
)
parser.add_argument(
"--frame-size",
type=int,
default=64,
help="Output square frame size in pixels. Default: 64.",
)
parser.add_argument(
"--anchor",
help="Optional anchor frame used to stabilize global scale and frame 01 output.",
)
parser.add_argument(
"--lock-frame1",
action="store_true",
help="Replace frame 01 with the provided anchor frame after normalization.",
)
parser.add_argument(
"--alpha-threshold",
type=int,
default=8,
help="Pixels with alpha above this threshold count as sprite content. Default: 8.",
)
return parser.parse_args()
def threshold_bbox(image: Image.Image, alpha_threshold: int) -> tuple[int, int, int, int] | None:
alpha = image.getchannel("A").point(lambda value: 255 if value > alpha_threshold else 0)
return alpha.getbbox()
def crop_to_content(image: Image.Image, alpha_threshold: int) -> Image.Image | None:
bbox = threshold_bbox(image, alpha_threshold)
if bbox is None:
return None
return image.crop(bbox)
def split_strip(strip: Image.Image, frames: int) -> list[Image.Image]:
if frames < 1:
raise ValueError("frames must be at least 1")
step = strip.width / frames
slots: list[Image.Image] = []
for index in range(frames):
left = int(round(index * step))
right = int(round((index + 1) * step))
slots.append(strip.crop((left, 0, right, strip.height)))
return slots
def max_content_size(images: Iterable[Image.Image | None]) -> tuple[int, int]:
widths: list[int] = []
heights: list[int] = []
for image in images:
if image is None:
continue
widths.append(image.width)
heights.append(image.height)
if not widths or not heights:
raise SystemExit("No sprite content was detected in the provided strip.")
return max(widths), max(heights)
def compose_frame(
image: Image.Image | None,
frame_size: int,
scale: float,
) -> Image.Image:
canvas = Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0))
if image is None:
return canvas
width = max(1, int(round(image.width * scale)))
height = max(1, int(round(image.height * scale)))
resized = image.resize((width, height), Image.Resampling.NEAREST)
offset_x = (frame_size - width) // 2
offset_y = frame_size - height
canvas.alpha_composite(resized, (offset_x, offset_y))
return canvas
def load_anchor(path: str | None, alpha_threshold: int) -> tuple[Image.Image | None, Image.Image | None]:
if path is None:
return None, None
anchor = Image.open(path).convert("RGBA")
cropped = crop_to_content(anchor, alpha_threshold)
return anchor, cropped
def main() -> None:
args = parse_args()
if args.frames < 1:
raise SystemExit("--frames must be at least 1.")
if args.frame_size < 1:
raise SystemExit("--frame-size must be positive.")
if args.lock_frame1 and not args.anchor:
raise SystemExit("--lock-frame1 requires --anchor.")
strip = Image.open(args.input).convert("RGBA")
slots = split_strip(strip, args.frames)
contents = [crop_to_content(slot, args.alpha_threshold) for slot in slots]
anchor_image, anchor_content = load_anchor(args.anchor, args.alpha_threshold)
max_width, max_height = max_content_size([*contents, anchor_content])
scale = min(args.frame_size / max_width, args.frame_size / max_height)
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
for index, content in enumerate(contents, start=1):
if index == 1 and args.lock_frame1:
assert anchor_image is not None
if anchor_image.width == args.frame_size and anchor_image.height == args.frame_size:
frame = anchor_image
else:
frame = compose_frame(anchor_content, args.frame_size, scale)
else:
frame = compose_frame(content, args.frame_size, scale)
frame.save(out_dir / f"{index:02d}.png")
if __name__ == "__main__":
main()
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Render a simple contact sheet from a directory of normalized sprite frames."""
from __future__ import annotations
import argparse
import math
import re
from pathlib import Path
try:
from PIL import Image, ImageDraw
except ImportError as exc: # pragma: no cover
raise SystemExit(
"Pillow is required. Install it with `python3 -m pip install pillow`."
) from exc
NUMBER_RE = re.compile(r"(\d+)")
def natural_key(path: Path) -> list[int | str]:
parts: list[int | str] = []
for chunk in NUMBER_RE.split(path.stem):
if not chunk:
continue
if chunk.isdigit():
parts.append(int(chunk))
else:
parts.append(chunk)
parts.append(path.suffix)
return parts
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Render a preview contact sheet from a directory of sprite frames."
)
parser.add_argument("--frames-dir", required=True, help="Directory containing PNG frames.")
parser.add_argument("--out", required=True, help="Output PNG path.")
parser.add_argument(
"--columns",
type=int,
default=4,
help="Number of columns in the preview sheet. Default: 4.",
)
parser.add_argument(
"--gap",
type=int,
default=8,
help="Gap between frames in pixels. Default: 8.",
)
return parser.parse_args()
def paint_checkerboard(image: Image.Image, tile: int = 16) -> None:
draw = ImageDraw.Draw(image)
colors = ((240, 243, 246, 255), (225, 230, 235, 255))
for top in range(0, image.height, tile):
for left in range(0, image.width, tile):
color = colors[((left // tile) + (top // tile)) % 2]
draw.rectangle((left, top, left + tile, top + tile), fill=color)
def main() -> None:
args = parse_args()
if args.columns < 1:
raise SystemExit("--columns must be at least 1.")
if args.gap < 0:
raise SystemExit("--gap cannot be negative.")
frame_dir = Path(args.frames_dir)
frames = sorted(frame_dir.glob("*.png"), key=natural_key)
if not frames:
raise SystemExit("No PNG frames were found in --frames-dir.")
images = [Image.open(path).convert("RGBA") for path in frames]
frame_width = max(image.width for image in images)
frame_height = max(image.height for image in images)
rows = math.ceil(len(images) / args.columns)
sheet_width = args.columns * frame_width + max(0, args.columns - 1) * args.gap
sheet_height = rows * frame_height + max(0, rows - 1) * args.gap
sheet = Image.new("RGBA", (sheet_width, sheet_height), (255, 255, 255, 255))
paint_checkerboard(sheet)
for index, image in enumerate(images):
row = index // args.columns
column = index % args.columns
left = column * (frame_width + args.gap) + (frame_width - image.width) // 2
top = row * (frame_height + args.gap) + (frame_height - image.height) // 2
sheet.alpha_composite(image, (left, top))
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
sheet.save(out_path)
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More