Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions MODULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,8 +430,11 @@ api.onInput((kind, code) => {}); // 'down'/'up' events; returns unsubscri
// pause the HOST's use of an input scope while your module drives:
// 'keys' — WASD camera fly + play-mode movement
// 'locomotion' — VR left-stick locomotion
// 'sticks' — (1.19) BOTH VR sticks: left-stick move, right-stick snap/smooth turn + teleport
// (a held thing reeled on Y and scaled on X, the way an Edit grab does)
api.claimInput('keys'); // ALWAYS release when your mode ends
api.releaseInput('keys');
const sticksOk = api.claimInput('sticks') === true; // true = this core knows the scope (older: undefined)

// reading a keydown YOURSELF (a toolbox's own handler)? resolve the key the way the
// editor does — `event.key` when it is an ASCII letter/digit, the physical
Expand Down Expand Up @@ -693,6 +696,8 @@ api.game.addSetting?.({
onChange: (v) => setBoard(v)
});
setBoard(api.game.setting?.('board') ?? 'globe'); // the stored choice at load
// (1.19) a CHOICE that decides which levels you see: `onLevels: true` also draws it as tabs
// above the Levels page's grid (desktop + headset) — Untangle's Board [Globe] [2D board]
api.game.setSetting?.('board', '2d'); // your in-game button writes the same row
api.game.onSettingsChange?.((values) => console.log(values.board, values.sfx));
```
Expand Down
47 changes: 47 additions & 0 deletions src/components/hud/GameShellMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
shellMenuAvailable,
shellMenuItems,
shellSettingViews,
shellLevelTabs,
shellPageTitle,
shellGameName,
shellHelpLines,
Expand All @@ -43,6 +44,9 @@
const items = $derived(itemsFor($gameLevels));
const viewsFor = (/** @type {any[]} */ ..._deps) => shellSettingViews({ vr: false });
const settings = $derived(viewsFor($gameSettingValues, $gameSettingRows));
// 33 (G3): a game's level-picking choice (Untangle's Board) as tabs above the grid
const tabsFor = (/** @type {any[]} */ ..._deps) => shellLevelTabs();
const levelTabs = $derived(tabsFor($gameSettingValues, $gameSettingRows));
const vrViewsFor = (/** @type {any[]} */ ..._deps) => shellSettingViews({ vr: true }).filter((/** @type {any} */ r) => r.vrOnly);
const vrSettings = $derived(vrViewsFor($gameSettingValues));
const helpFor = (/** @type {any[]} */ ..._deps) => shellHelpLines({ vr: false });
Expand Down Expand Up @@ -150,6 +154,22 @@
{/each}
</div>
{:else if page === 'levels'}
{#each levelTabs as tab (tab.id)}
<div class="gs-tabs" role="tablist" aria-label={tab.label} data-shell-tabs={tab.id}>
<span class="gs-tabs-label">{tab.label}</span>
{#each tab.options as option (option.value)}
<button
type="button"
role="tab"
class="gs-tab"
class:gs-tab-on={option.value === tab.value}
aria-selected={option.value === tab.value}
data-shell-tab={tab.id + ':' + option.value}
onclick={() => option.value !== tab.value && setGameSetting(tab.id, option.value)}>{option.label}</button
>
{/each}
</div>
{/each}
<div class="gs-levels">
{#each $gameLevels?.list ?? [] as level (level.id)}
<button
Expand Down Expand Up @@ -340,6 +360,8 @@
}
.gs-item:hover,
.gs-item:focus-visible,
.gs-tab:hover:not(.gs-tab-on),
.gs-tab:focus-visible,
.gs-level:hover:not(:disabled),
.gs-level:focus-visible {
border-color: #5fd0ff;
Expand All @@ -362,6 +384,31 @@
opacity: 0.75;
padding: 8px;
}
.gs-tabs {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
.gs-tabs-label {
margin-right: 6px;
font-size: 14px;
font-weight: 600;
opacity: 0.8;
}
.gs-tab {
flex: 1;
min-height: 40px;
padding: 6px 12px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid transparent;
font-weight: 600;
}
.gs-tab-on {
background: var(--accent, #ef562f);
color: #fff;
}
.gs-levels {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
Expand Down
3 changes: 2 additions & 1 deletion src/components/play/VRControls.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
const session = renderer.xr.getSession();
if (!session) return;
if ($vrMenuOpen || $vrObjectsPanelOpen) return; // menu/panel own the sticks (74/101)
if ($inputClaims.includes('locomotion')) return; // K-C: a module drives instead
// K-C: a module drives instead; 33: 'sticks' claims BOTH sticks (move, turn, teleport)
if ($inputClaims.includes('locomotion') || $inputClaims.includes('sticks')) return;
// 30b P3: INTERACT walks like a game (collision, gravity, step-up; no fly or teleport
// unless the play block allows them) — a held object does not stop your feet
if (tickVRInteractLocomotion(delta, session)) return;
Expand Down
5 changes: 4 additions & 1 deletion src/lib/gameSettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { safeStorage } from './safeStorage';
/**
* @typedef {{id: string, label: string, type: 'toggle' | 'choice' | 'range', options?: string[],
* optionLabels?: string[], min?: number, max?: number, step?: number, default: any,
* help?: string, vrOnly?: boolean, onChange?: (value: any) => void, owner?: string}} SettingRow
* help?: string, vrOnly?: boolean, onLevels?: boolean, onChange?: (value: any) => void, owner?: string}} SettingRow
*/

/** The rows every game gets, in panel order. `vrOnly` rows are drawn only in a headset
Expand Down Expand Up @@ -129,6 +129,9 @@ export function normalizeSettingRow(row) {
if (typeof row.onChange === 'function') out.onChange = row.onChange;
if (row.help) out.help = String(row.help).slice(0, 200);
if (row.vrOnly) out.vrOnly = true;
// 33 (G3): a game's CHOICE that picks which levels you see (Untangle's Board: Globe / 2D) is
// also drawn as tabs above the Levels page's grid — desktop and headset
if (row.onLevels && type === 'choice') out.onLevels = true;
return out;
}

Expand Down
46 changes: 41 additions & 5 deletions src/lib/gameShell.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,19 @@ import { CORE_SETTINGS, gameSettingRows, gameSettingValues, setGameSetting, sett

/** @typedef {'main' | 'levels' | 'settings' | 'help'} ShellPage */

/** The menu: open, and which page. LOCAL. @type {import('svelte/store').Writable<{open: boolean, page: ShellPage}>} */
export const shellMenu = writable({ open: false, page: /** @type {ShellPage} */ ('main') });
/** The menu: open, which page, and (33) the headset's page of the level grid — null = the page
* holding the current level. LOCAL. @type {import('svelte/store').Writable<{open: boolean, page: ShellPage, levelPage?: number | null}>} */
export const shellMenu = writable({ open: false, page: /** @type {ShellPage} */ ('main'), levelPage: /** @type {number | null} */ (null) });

/** @param {ShellPage} [page] */
export function openShellMenu(page = 'main') {
if (!shellMenuAvailable()) return false;
shellMenu.set({ open: true, page: normalizePage(page) });
shellMenu.set({ open: true, page: normalizePage(page), levelPage: null });
debug.opens++;
return true;
}
export function closeShellMenu() {
if (get(shellMenu).open) shellMenu.set({ open: false, page: 'main' });
if (get(shellMenu).open) shellMenu.set({ open: false, page: 'main', levelPage: null });
}
/** @returns {boolean} open after the toggle */
export function toggleShellMenu() {
Expand All @@ -56,7 +57,7 @@ export function toggleShellMenu() {
}
/** @param {ShellPage} page */
export function showShellPage(page) {
shellMenu.update((m) => ({ ...m, page: normalizePage(page) }));
shellMenu.update((m) => ({ ...m, page: normalizePage(page), levelPage: null }));
}
/** @param {any} page @returns {ShellPage} */
function normalizePage(page) {
Expand Down Expand Up @@ -353,6 +354,28 @@ export function shellSettingViews(opts = {}) {
return out;
}

/**
* 33 (G3): the game's choices drawn as TABS above the Levels page (a setting registered with
* `onLevels: true` — Untangle's Board: Globe / 2D board), with the option on now. Both
* renderers read it. @returns {{id: string, label: string, options: {value: string, label: string}[], value: string}[]}
*/
export function shellLevelTabs() {
const values = get(gameSettingValues);
return get(gameSettingRows)
.filter((row) => row.onLevels && row.type === 'choice')
.map((row) => ({
id: row.id,
label: row.label,
options: (row.options ?? []).map((o, i) => ({ value: o, label: String(row.optionLabels?.[i] ?? o) })),
value: String(values[row.id] ?? row.default)
}));
}

/** 33: the headset's level grid shows page `n` (0-based; clamped where it is drawn). @param {number} n */
export function showLevelPage(n) {
shellMenu.update((m) => ({ ...m, levelPage: Math.max(0, Math.floor(Number(n) || 0)) }));
}

/**
* Step a setting the way a controller does: a toggle flips, a choice walks (dir ±1,
* wrapping), a range moves by its step (clamped). Returns the new value.
Expand Down Expand Up @@ -406,6 +429,19 @@ export function pressShellHit(hitId) {
if (parts[1] === 'item') return runShellItem(parts.slice(2).join(':'));
if (parts[1] === 'back') return runShellItem('back');
if (parts[1] === 'level') return pickGameLevel(parts.slice(2).join(':'));
// 33: a tab above the grid (tab:<setting index>:<option index> of shellLevelTabs) and the
// headset grid's page arrows (lvpage:<page>)
if (parts[1] === 'tab') {
const tab = shellLevelTabs()[Number(parts[2])];
const option = tab?.options[Number(parts[3])];
if (!tab || !option) return false;
if (option.value !== tab.value) setGameSetting(tab.id, option.value);
return true;
}
if (parts[1] === 'lvpage') {
showLevelPage(Number(parts[2]));
return true;
}
if (parts[1] === 'set') {
const dir = parts[parts.length - 1] === 'prev' ? -1 : 1;
const id = parts.slice(2, parts.length - 1).join(':');
Expand Down
6 changes: 3 additions & 3 deletions src/lib/inputRuntime.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,12 @@ function emit(kind, code) {
* @type {import('svelte/store').Writable<string[]>} */
export const inputClaims = writable([]);

/** Is a scope currently claimed by any module? @param {'keys'|'locomotion'} scope */
/** Is a scope currently claimed by any module? @param {'keys'|'locomotion'|'sticks'} scope */
export function isClaimed(scope) {
return get(inputClaims).includes(scope);
}

/** @param {'keys'|'locomotion'} scope */
/** @param {'keys'|'locomotion'|'sticks'} scope */
// 21-E3: REFCOUNTED. The claim set used to be a plain membership list, so two
// claimers of the same scope (a HUD menu + a module possess) dropped each other:
// the first release removed the scope for both. editorNavigation refused to claim
Expand All @@ -78,7 +78,7 @@ export function claimInput(scope) {
if (next === 1) inputClaims.update((list) => (list.includes(scope) ? list : [...list, scope]));
}

/** @param {'keys'|'locomotion'} scope */
/** @param {'keys'|'locomotion'|'sticks'} scope */
/** @param {string} scope */
export function releaseInput(scope) {
const next = Math.max(0, (claimCounts.get(scope) ?? 0) - 1);
Expand Down
20 changes: 14 additions & 6 deletions src/lib/moduleSDK.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ import { playGameMusic, stopGameMusic, gameMusicState, MUSIC_PRESET_IDS } from '
import { announce as announceBanner, clearAnnouncement } from './gameAnnounce';
/** the ping chimes `api.playSound` still reaches (pingAudio's PING_SOUNDS ids) */
const PING_NAMES = new Set(['ding', 'chime', 'pluck', 'bell']);
/** the input scopes `api.claimInput` pauses (33: 'sticks' = both VR sticks) */
const INPUT_SCOPES = ['keys', 'locomotion', 'sticks'];
import { runtimeSpawn, setRuntimeSpawn } from './playSettings'; // 30b P4 (a leaf)
import { spawnDesktopPlayer, currentSpawn, desktopSpawn, spawnEyePose } from './playSpawn'; // 30b P4 (a leaf)

Expand Down Expand Up @@ -444,7 +446,7 @@ function makeApi(moduleId, moduleName = moduleId) {
flowGraphsRef?.recordFlowNodesEntry({ op: 'data', graphId: items[0].graphId, items, moduleId });
};
/** input scopes this module still holds — released at teardown
* @type {Set<'keys'|'locomotion'>} */
* @type {Set<'keys'|'locomotion'|'sticks'>} */
const claimedScopes = new Set();
let possessing = false;
/** list elements this module has pushed rows into, so teardown clears exactly those and
Expand Down Expand Up @@ -941,15 +943,19 @@ function makeApi(moduleId, moduleName = moduleId) {
return off;
},
/** Pause the host's own use of an input scope while your module drives:
* 'keys' (WASD camera fly / play movement) or 'locomotion' (VR left stick).
* ALWAYS release (module disable/error releases everything).
* @param {'keys'|'locomotion'} scope */
* 'keys' (WASD camera fly / play movement), 'locomotion' (VR left stick), or
* 'sticks' (33: BOTH VR sticks — left-stick move, right-stick turn and teleport — for a
* module that reads `input().axes` itself, e.g. reel/scale a held thing on the sticks).
* ALWAYS release (module disable/error releases everything). Returns true when this
* core knows the scope (an older core answers undefined: feature-detect 'sticks' so).
* @param {'keys'|'locomotion'|'sticks'} scope */
claimInput(scope) {
claimedScopes.add(scope);
if (inputRuntimeRef) inputRuntimeRef.claimInput(scope);
else import('./inputRuntime').then((m) => m.claimInput(scope));
return INPUT_SCOPES.includes(scope);
},
/** @param {'keys'|'locomotion'} scope */
/** @param {'keys'|'locomotion'|'sticks'} scope */
releaseInput(scope) {
claimedScopes.delete(scope);
if (inputRuntimeRef) inputRuntimeRef.releaseInput(scope);
Expand Down Expand Up @@ -1340,7 +1346,9 @@ function makeApi(moduleId, moduleName = moduleId) {
* rows: music, sound effects, haptics, FPS, turning, vignette, quality). Persisted
* per game on this device. `type` 'toggle' | 'choice' (with `options`, optional
* `optionLabels`) | 'range' (`min`/`max`/`step`). `onChange(value)` hears a change.
* @param {{id: string, label: string, type?: 'toggle'|'choice'|'range', options?: string[], optionLabels?: string[], min?: number, max?: number, step?: number, default: any, onChange?: (value: any) => void}} row
* 33: a choice with `onLevels: true` is ALSO drawn as tabs above the Levels page's
* grid (desktop + VR) — a choice that decides which levels you see (Untangle's Board).
* @param {{id: string, label: string, type?: 'toggle'|'choice'|'range', options?: string[], optionLabels?: string[], min?: number, max?: number, step?: number, default: any, onLevels?: boolean, onChange?: (value: any) => void}} row
* @returns {(() => void) | null} off, or null when refused (a core id, a bad row)
*/
addSetting(row) {
Expand Down
72 changes: 59 additions & 13 deletions src/lib/shellPanelDraw.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,39 @@ function roundRect(g, x, y, w, h, r) {
* items: {id: string, label: string}[],
* levels: {list: {id: string, label: string, locked?: boolean, stars?: number}[], current: string | null} | null,
* settings: {id: string, label: string, type: string, value: any, display: string, min?: number, max?: number, game?: boolean}[],
* help: string[], fps?: string | null}} ShellModel
* help: string[], fps?: string | null,
* tabs?: {id: string, label: string, options: {value: string, label: string}[], value: string}[],
* levelPage?: number | null}} ShellModel
*/

/** the sub-pages' content starts here, in stage px */
const PAGE_TOP = 110;
/** one tab row above the level grid (50 px tabs + the gap under them) */
const TAB_ROW = 64;

/**
* 33 (G3) — the headset's level grid. It drew `list.slice(0, 20)`: Untangle's levels 21-30 were
* unreachable in VR. Up to 20 levels and no tabs: the 5 x 4 of big tiles it always had. More, or
* tabs above the grid: 6 columns, as many rows of at least 64 px as fit; when even that is not
* enough, pages (the ‹ › arrows beside Back). `page` null = the page holding the current level.
* Pure; exported for the suites.
* @param {number} count @param {number} tabRows @param {number | null | undefined} page @param {number} currentIndex
*/
export function levelGridLayout(count, tabRows, page, currentIndex) {
const top = PAGE_TOP + tabRows * TAB_ROW;
if (!tabRows && count <= 20) return { cols: 5, rows: 4, tw: 190, th: 96, gap: 14, top, perPage: 20, pages: 1, page: 0 };
const cols = 6;
const gap = 12;
const bottom = SHELL_STAGE.h - 72 - 14; // above Back
const areaH = bottom - top;
const rows = Math.max(1, Math.floor((areaH + gap) / (64 + gap)));
const th = Math.min(96, (areaH - (rows - 1) * gap) / rows);
const perPage = cols * rows;
const pages = Math.max(1, Math.ceil(count / perPage));
const want = page ?? (currentIndex >= 0 ? Math.floor(currentIndex / perPage) : 0);
return { cols, rows, tw: 160, th, gap, top, perPage, pages, page: Math.min(Math.max(0, want), pages - 1) };
}

/**
* Paint one shell page over the (already cleared + plated) board. `k` = canvas px per
* stage px. Returns the hits in CANVAS pixels, `kind: 'shell'`.
Expand Down Expand Up @@ -76,7 +106,7 @@ export function drawShellPage(g, model, opts) {
g.font = `500 ${Math.round(20 * k)}px system-ui, sans-serif`;
g.fillText(model.subtitle + (model.fps ? ' · ' + model.fps : ''), W / 2, 84 * k);

const top = 110;
const top = PAGE_TOP;
if (model.page === 'main') {
const bw = 520;
const bh = 56;
Expand All @@ -93,22 +123,38 @@ export function drawShellPage(g, model, opts) {

if (model.page === 'levels') {
const list = model.levels?.list ?? [];
const cols = 5;
const tw = 190;
const th = 96;
const gap = 14;
const x0 = (SHELL_STAGE.w - (cols * tw + (cols - 1) * gap)) / 2;
list.slice(0, 20).forEach((level, i) => {
const x = x0 + (i % cols) * (tw + gap);
const y = top + Math.floor(i / cols) * (th + gap);
// 33 (G3): the game's level-picking choices as tabs (Untangle: Board [Globe] [2D board])
const tabs = (model.tabs ?? []).slice(0, 2);
tabs.forEach((tab, t) => {
const y = top + t * TAB_ROW;
g.textAlign = 'right';
g.textBaseline = 'middle';
g.fillStyle = '#cbd5e1';
g.font = `600 ${Math.round(24 * k)}px system-ui, sans-serif`;
const tw = Math.min(260, (SHELL_STAGE.w - 400) / Math.max(1, tab.options.length));
const x0 = (SHELL_STAGE.w - tab.options.length * (tw + 12) + 12) / 2 + 70;
g.fillText(tab.label, (x0 - 20) * k, (y + 25) * k);
tab.options.forEach((option, o) => {
button('shell:tab:' + t + ':' + o, option.label, x0 + o * (tw + 12), y, tw, 50, { accent: option.value === tab.value });
});
});
const current = list.findIndex((level) => level.id === model.levels?.current);
const grid = levelGridLayout(list.length, tabs.length, model.levelPage, current);
const x0 = (SHELL_STAGE.w - (grid.cols * grid.tw + (grid.cols - 1) * grid.gap)) / 2;
list.slice(grid.page * grid.perPage, (grid.page + 1) * grid.perPage).forEach((level, i) => {
const x = x0 + (i % grid.cols) * (grid.tw + grid.gap);
const y = grid.top + Math.floor(i / grid.cols) * (grid.th + grid.gap);
const stars = level.stars ? '★'.repeat(level.stars) : '';
const current = model.levels?.current === level.id;
button('shell:level:' + level.id, level.label, x, y, tw, th, {
button('shell:level:' + level.id, level.label, x, y, grid.tw, grid.th, {
disabled: !!level.locked,
accent: current,
accent: model.levels?.current === level.id,
sub: level.locked ? 'Locked' : stars
});
});
// more pages: the arrows beside Back name the page they go to
const backX = (SHELL_STAGE.w - 300) / 2;
if (grid.page > 0) button('shell:lvpage:' + (grid.page - 1), '‹ Page ' + grid.page, backX - 12 - 200, backY, 200, 54);
if (grid.page < grid.pages - 1) button('shell:lvpage:' + (grid.page + 1), 'Page ' + (grid.page + 2) + ' ›', backX + 300 + 12, backY, 200, 54);
return hits;
}

Expand Down
Loading
Loading