一键导入
game-menu-system
Build a data-driven, attribute-controlled game menu system with concurrent gameplay and simulation, custom actions, and in-place value updates.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Build a data-driven, attribute-controlled game menu system with concurrent gameplay and simulation, custom actions, and in-place value updates.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
Airship One implementation playbook for runtime architecture, npm-first tooling, release/PWA flow, and debugging checks.
Airship One UI and visual design guardrails for square-viewport composition, rem-based sizing, and 24-row text rhythm.
Airship One modular 3D asset authoring standard for interior modules, walk/collision volumes, and build-time texel-consistent texture atlases.
基于 SOC 职业分类
| name | game-menu-system |
| description | Build a data-driven, attribute-controlled game menu system with concurrent gameplay and simulation, custom actions, and in-place value updates. |
Build a menu system that is data-driven, attribute-controlled, and non-blocking (gameplay and simulation continue while menus are open). This skill defines patterns used in IdleGames to keep UI consistent, fast, and accessible.
Define all menus in a single MENUS object. Menus are pure data.
Implementation note for larger runtimes:
src/ui/menu-definitions.ts).src/ui/menu-render.ts).src/main.ts).const MENUS = {
computerRoom: {
isRoot: true,
title: 'COMPUTER',
overview: 'Computer Room systems access',
items: [
{ type: 'action', label: 'System Status', action: () => openMenu('computerSystemStatus') },
{ type: 'action', label: 'Crew Automation', action: () => openMenu('crewCommand') }
],
actions: [{ label: 'Back', behavior: 'back' }]
},
crewCommand: {
isRoot: false,
title: 'CREW COMMAND',
overview: 'Configure crew automation:',
itemBuilder: 'buildCrewCommandItems',
actions: [{ label: 'Back', behavior: 'back' }]
}
};
Every action has a behavior attribute that drives navigation:
keep-open: update values in-place, no DOM rebuildback: pop to previous menusubmenu: open another menuclose: close the panelclose-all: clear stack + close{ label: 'Toggle', action: toggle('autoRepair'), behavior: 'keep-open' }
{ label: 'Back', behavior: 'back' }
The menu stack holds the current breadcrumb trail. Root menus replace the stack; submenus push onto it.
if (menuDef.isRoot) {
state.ui.unifiedMenuStack = [stackEntry];
} else {
state.ui.unifiedMenuStack.push(stackEntry);
}
Use action buttons consistently:
behavior: 'close').behavior: 'back').This prevents ambiguous navigation and keeps stack behavior predictable when menus are opened directly from world interactions.
refreshUnifiedMenuValues() - Update values in-place without DOM rebuild:
rebuildUnifiedMenuInPlace() - Full DOM rebuild:
function handleMenuBehavior(behavior, target, menuDef) {
switch (behavior) {
case 'keep-open': {
const current = state.ui.unifiedMenuStack.at(-1);
let items = current.definition.items || [];
if (current.definition.itemBuilder) {
const builder = MENU_HANDLERS[current.definition.itemBuilder];
if (typeof builder === 'function') items = builder();
}
current.items = items;
// Only updates values - fast, preserves focus
refreshUnifiedMenuValues(current.definition, items);
break;
}
}
}
Use data attributes to map items to DOM elements.
function renderUnifiedSettingItem(container, item, menuDef) {
const row = document.createElement('div');
row.className = 'menu-setting-item';
row.dataset.menuLabel = item.label;
const value = document.createElement('div');
value.className = 'menu-setting-value';
value.textContent = item.value || '';
row.appendChild(value);
container.appendChild(row);
}
function refreshUnifiedMenuValues(menuDef, items) {
const menuItemsEl = document.getElementById('panelMenuItems');
if (!menuItemsEl) return;
for (const item of items) {
if (item.type === 'setting') {
const row = [...menuItemsEl.querySelectorAll('.menu-setting-item')]
.find(el => el.dataset.menuLabel === item.label);
if (row) {
const valueEl = row.querySelector('.menu-setting-value');
if (valueEl) valueEl.textContent = item.value || '';
}
}
}
}
For frequently changing simulator values (power, fuel, temperatures), prefer a queue-driven, in-place binding approach that never rebuilds the menu DOM while it is visible.
Pattern:
liveValueKey).Map<key, HTMLElement>).renderMenu() call).keep-open behavior for toggle actions, but use a fast path that drains queue + applies live bindings and returns without redraw.// 1) Bind during render
if (item.liveValueKey) {
liveMenuValueBindings.set(item.liveValueKey, valueEl);
}
// 2) Emit only on change
function enqueueStatsIfChanged(stats) {
const nextSig = serialize(stats);
if (nextSig === lastSig) return;
lastSig = nextSig;
queue.push({ type: 'ui/menu-stats', stats });
}
// 3) Consume and update in-place
function applyLiveStats(stats) {
if (!isTargetMenuVisible()) return;
for (const [key, element] of liveMenuValueBindings) {
element.textContent = toText(key, stats);
}
}
// 4) keep-open fast path (no redraw)
if (behavior === 'keep-open' && isTargetMenuVisible()) {
drainEvents();
applyLiveStats(currentStats());
return; // preserve focus + scroll + DOM identity
}
Why this is preferred:
When you introduce a new item type (for example, a feed entry with metadata + buttons), add a dedicated renderer and wire it in renderUnifiedMenu.
// In renderUnifiedMenu
if (item.type === 'spacebook') {
renderUnifiedSpacebookItem(menuItemsEl, item, menuDef);
}
// Dedicated renderer
function renderUnifiedSpacebookItem(container, item, menuDef) {
const wrapper = document.createElement('div');
wrapper.className = 'spacebook-post';
// ...header/meta/content and per-item actions...
container.appendChild(wrapper);
}
For readable in-world documents (letters, logs, memos), prefer one structured custom item over multiple spacer text rows.
{
type: 'letter',
from: 'Chief Engineer',
to: 'Captain',
subject: 'Routing Update',
dateUtc: '2026-02-14 22:10:00 UTC',
paragraphs: ['Paragraph 1', 'Paragraph 2']
}
Renderer guidance:
paragraphs[].Use a table item when you need rows with button actions. Each row is an array of cells; button cells use { type: 'button', label, action, behavior }.
function buildCrewRosterItems() {
const rows = listRosterEntries().map((entry) => ({
cells: [
entry.typeLabel,
entry.name,
entry.role.toUpperCase(),
entry.onDuty ? 'YES' : 'NO',
{
type: 'button',
label: 'Rename',
action: () => openMenu('crewRosterRename', { uid: entry.uid })
},
{
type: 'button',
label: 'Role',
action: () => openMenu('crewRosterRole', { uid: entry.uid })
}
]
}));
return [
{
type: 'table',
columns: ['Type', 'Name', 'Role', 'On Duty', 'Rename', 'Role'],
rows
}
];
}
Render buttons inside renderUnifiedTableItem and wire behavior through handleMenuBehavior so table actions can keep the menu open or navigate.
When items are added/removed (crew hired, inventory exhausted), rebuild the DOM:
function rebuildUnifiedMenuInPlace() {
if (state.ui.unifiedMenuStack.length === 0) return;
const current = state.ui.unifiedMenuStack[state.ui.unifiedMenuStack.length - 1];
let items = current.definition.items || [];
if (current.definition.itemBuilder) {
const builder = MENU_HANDLERS[current.definition.itemBuilder];
if (typeof builder === 'function') {
items = builder();
}
}
current.items = items;
renderUnifiedMenu(current.definition, items, { _skipPush: true });
}
// Use in actions that change list structure
function buildTransferCrewOutItems(options = {}) {
const npcs = state.npcs.filter(Boolean);
const items = [/* ... */];
for (const n of npcs) {
items.push({
type: 'action',
label: `Dismiss ${n.name}`,
action: () => {
removeCrewMemberById(n.id);
updateHUD();
markDirty();
rebuildUnifiedMenuInPlace(); // Rebuild to remove button
},
danger: true,
behavior: 'keep-open'
});
}
return items;
}
CRITICAL: Never call openMenu() within an action to refresh. Let behavior: 'keep-open' handle it.
const toggle = (k) => () => {
state.crewAI[k] = !state.crewAI[k];
markDirty();
// NO openMenu() call - keep-open handles refresh
};
{ type: 'setting', label: 'Auto Repair', value: state.crewAI.autoRepair ? 'ON' : 'OFF',
actions: [{ label: 'Toggle', action: toggle('autoRepair'), behavior: 'keep-open' }] }
Use rebuildUnifiedMenuInPlace() when the action changes the list structure:
function buildTransferCrewInItems(options = {}) {
const fee = 30;
const candidates = ['CREW', 'TECH', 'MEDIC', /* ... */];
const items = [
{ type: 'setting', label: 'Transport Fee', value: `${fee} credits`, actions: [] },
{ type: 'setting', label: 'Available Credits', value: `${Math.floor(state.credits)}`, actions: [] },
{ type: 'divider' }
];
for (const name of candidates) {
items.push({
type: 'action',
label: `Hire ${name} (have ${have})`,
action: () => {
if (state.credits < fee) {
openMenu('transferCrewInInsufficientCredits'); // Navigate to error submenu
return;
}
state.credits -= fee;
addCrewMember(name);
updateHUD();
**NEVER call `openMenu()` within an action to refresh** - let `behavior: 'keep-open'` handle it.
- [ ] Use `rebuildUnifiedMenuInPlace()` when items are added/removed from lists.
- [ ] Use `refreshUnifiedMenuValues()` (automatic via keep-open) for value-only updates.
- [ ] Error/status messages use dedicated submenus, not dialog popups.
- [ ] Root menus show **Close**, submenus show **Back**.
- [ ] Focus returns to game canvas on close.
## Common Mistakes to Avoid
1. **Calling `openMenu()` to refresh the current menu**
```js
// ❌ WRONG - creates duplicate menu stack entries
action: () => {
state.credits -= 10;
openMenu('transferCrewIn'); // Don't do this!
}
// ✅ CORRECT - let keep-open handle refresh
action: () => {
state.credits -= 10;
markDirty();
},
behavior: 'keep-open'
Using dialog popups instead of submenus
// ❌ WRONG - breaks unified menu flow
if (state.credits < fee) {
showMessage('QUARTERS', 'Need more credits');
return;
}
// ✅ CORRECT - navigate to error submenu
if (state.credits < fee) {
openMenu('transferCrewInInsufficientCredits');
return;
}
Not rebuilding when list structure changes
// ❌ WRONG - button stays even after dismissing
action: () => {
removeCrewMemberById(n.id);
// Missing rebuildUnifiedMenuInPlace()
}
// ✅ CORRECT - rebuild to remove button
action: () => {
removeCrewMemberById(n.id);
updateHUD();
markDirty();
rebuildUnifiedMenuInPlace();
}
danger: !affordable, behavior: 'keep-open' }); }
return items; }
### 3) Error/Status Submenus
Create dedicated submenus for errors instead of dialog popups:
```js
// Menu definition
transferCrewInInsufficientCredits: {
isRoot: false,
stationType: 'quarters',
title: 'INSUFFICIENT CREDITS',
overview: 'Cannot afford this hire:',
itemBuilder: 'buildTransferCrewInInsufficientCreditsItems',
actions: [
{ label: 'Back', behavior: 'back' }
]
},
// Item builder
function buildTransferCrewInInsufficientCreditsItems(options = {}) {
const fee = 30;
return [
{ type: 'setting', label: 'Required Credits', value: `${fee}`, actions: [] },
{ type: 'setting', label: 'Available Credits', value: `${Math.floor(state.credits)}`, actions: [] },
{ type: 'setting', label: 'Shortage', value: `${fee - Math.floor(state.credits)}`, actions: [] }
];
}
{ type: 'action', label: 'System Status', action: () => openMenu('computerSystemStatus'), behavior: 'submenu' }
If isRoot and the action is Back, render it as Close and set behavior to close.
Menus should not pause the simulation. Keep game state updates independent and let the menu update values via refreshUnifiedMenuValues.
if (document.activeElement && document.activeElement !== document.body) {
document.activeElement.blur();
}
const gameCanvas = document.getElementById('gameCanvas');
if (gameCanvas?.focus) gameCanvas.focus();
// Inside the menu key handler
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
// ...menu handling...
}
MENUS data.behavior attributes.keep-open does in-place updates only.openMenu to refresh itself.