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
19 changes: 15 additions & 4 deletions src/App.svelte

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/components/editors/Nodes.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,9 @@
ongamestate: AnimationNode,
setvariable: AnimationNode,
getvariable: AnimationNode,
// 30 P4: the device-local pair (the second registry — the catalog is the first)
storevalue: AnimationNode,
storedvalue: AnimationNode,
gametime: AnimationNode,
// 21-F3's `collectcount` card MOVED to the collectible module (R3a) — an old
// scene's node renders as UnknownNode until the module is installed, honestly
Expand Down
111 changes: 111 additions & 0 deletions src/components/hud/GameChip.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<script>
// 30 P1 — THE EDITOR STOPS MASQUERADING AS THE GAME (roadmap 30 fork 4).
//
// A scene with a game shell used to paint its menu over the EDITOR — "TOWERS / Start
// round" sitting on top of the scene you were building, its buttons live, so a Start
// press in the editor started the round for every peer without anybody pressing Play.
// HudLayer now keeps a game's screens off the viewport outside play (the HUD editor's
// preview eye still shows them, inert). This chip is what stands in for them: one
// line saying the scene IS a game and where its state is, and the one button an
// author needs to try it — ▶ Test play (back to the menu, enter play, Start screen).
//
// LOCAL chrome: editor only, never in embed mode (an embed has its own ▶), never in a
// headset (DOM is invisible there), and it replicates nothing — Test play's reset goes
// through the game shell's own write path, which is what replicates.
import { Play, Eye, EyeOff } from '@lucide/svelte';
import { isLocked, isVRMode } from '../../stores/sceneStore';
import { hudIsGame, hudPreviewInViewport } from '$lib/hudDocs';
import { gameState } from '$lib/gameState';
import { embedMode } from '$lib/playMode';
import { testPlay } from '$lib/gamePresence';

const visible = $derived($hudIsGame && $isLocked !== true && !$isVRMode && !$embedMode);
const stateLabel = $derived(String($gameState?.state ?? 'menu'));
</script>

{#if visible}
<div id="game-chip" class="game-chip" data-game-state={stateLabel}>
<span class="game-chip-label">Game · <span class="game-chip-state">{stateLabel}</span></span>
<button
id="game-chip-preview"
type="button"
class="game-chip-icon"
aria-pressed={$hudPreviewInViewport}
aria-label={$hudPreviewInViewport ? 'Hide the game screens in the editor' : 'Show the game screens in the editor (preview, buttons inert)'}
title={$hudPreviewInViewport ? 'Hide the game screens in the editor' : 'Preview the game screens in the editor (buttons inert)'}
onclick={() => hudPreviewInViewport.update((on) => !on)}
>
{#if $hudPreviewInViewport}<Eye size={14} aria-hidden="true" />{:else}<EyeOff size={14} aria-hidden="true" />{/if}
</button>
<button
id="game-chip-test"
type="button"
class="game-chip-test"
title="Test play: back to the menu, enter Play, start from the Start screen"
onclick={() => testPlay()}
>
<Play size={13} aria-hidden="true" />
<span>Test play</span>
</button>
</div>
{/if}

<style>
.game-chip {
position: fixed;
/* under the logo (8..56px), and under the Connect bar when that is docked full-width
across the top — `--connect-bottom` is 0 otherwise */
top: calc(var(--connect-bottom, 0px) + 68px);
left: 16px;
/* the authoring HUD band: over the viewport, under every window (--z-window 40) */
z-index: 38;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 4px 4px 10px;
border-radius: 9999px;
background: var(--surface, #1f2937);
color: var(--icon-strong, #e5e7eb);
border: 1px solid var(--border, rgba(255, 255, 255, 0.12));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
font-size: 12px;
line-height: 1;
user-select: none;
}
.game-chip-label {
white-space: nowrap;
opacity: 0.85;
}
.game-chip-state {
font-weight: 600;
}
.game-chip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 9999px;
color: inherit;
opacity: 0.8;
}
.game-chip-icon:hover,
.game-chip-icon[aria-pressed='true'] {
opacity: 1;
background: rgba(255, 255, 255, 0.08);
}
.game-chip-test {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 10px 5px 8px;
border-radius: 9999px;
background: var(--accent, #ef562f);
color: #fff;
font-weight: 600;
white-space: nowrap;
}
.game-chip-test:hover {
filter: brightness(1.08);
}
</style>
28 changes: 23 additions & 5 deletions src/components/hud/HudLayer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// phase (the AnnotationPins / AnnotationMarkers split).
import { onMount } from 'svelte';
import HudElement from './HudElement.svelte';
import { hudDocs, hudRuntime, hudScreenOverride, hudPreviewInViewport, visibleScreen, activeHudKeys, setHudValue, hudValueOf } from '$lib/hudDocs';
import { hudDocs, hudRuntime, hudScreenOverride, hudPreviewInViewport, hudIsGame, visibleScreen, activeHudKeys, setHudValue, hudValueOf } from '$lib/hudDocs';
import { isVRMode, isLocked, playPointerFree } from '../../stores/sceneStore';
import { hudEditorClose } from '../../stores/appStore.js';
import { viewportOverrides, renderLayer } from '$lib/viewportOverrides';
Expand Down Expand Up @@ -62,6 +62,17 @@
// authoring. `=== false` would read the transient value and flip back a moment later.
const playing = $derived($isLocked === true);

// 30 P1: THE EDITOR STOPS MASQUERADING AS THE GAME. Outside Play a GAME's screens (the
// `hudIsGame` rule: a screen bound to a game state) are not drawn at all — the menu of
// a Games-tab game used to sit over the scene being built, with a LIVE Start that began
// the round for every peer with nobody in Play. The game chip (GameChip.svelte) stands
// in for them. The HUD editor's preview eye still draws them for authoring, and then
// every control is INERT (`editor` on HudElement — the artboard's own swallow), so
// laying out a menu can never fire the game. A HUD with no state-bound screen is a
// plain overlay and keeps its old behaviour exactly.
const gameInEditor = $derived(!playing && $hudIsGame);
const gameHidden = $derived(gameInEditor && !$hudPreviewInViewport);

// BOTH stores are read as dependencies. `visibleScreen` reaches the override through
// `get()`, and a `get()` inside a $derived registers NOTHING — so with only $hudDocs
// here, showing a screen wrote the store and the layer never re-rendered. It looked
Expand Down Expand Up @@ -103,7 +114,7 @@
// dropdowns, and under pointer lock the ring is the only hand a player has. A
// disabled control is skipped the way it ignores presses.
const focusables = $derived(elements.filter((el) => isInteractiveKind(el.kind) && el.enabled !== false));
const anyVisible = $derived(elements.length > 0 && !$isVRMode && layerAllowed && !authoringHidden);
const anyVisible = $derived(elements.length > 0 && !$isVRMode && layerAllowed && !authoringHidden && !gameHidden);

// ---- 21-E3: the MENU SUBSTATE - this component is the SINGLE WRITER ------------
// Visibility IS the state: any visible screen marked input:'menu' while playing
Expand Down Expand Up @@ -286,7 +297,8 @@
// Claim only while a screen with focusables is actually up, and release the moment it
// is not — a claim left standing pauses editor fly for good.
$effect(() => {
const wants = anyVisible && focusables.length > 0;
// 30 P1: an INERT preview has nothing to drive, so it must not pause editor flight
const wants = anyVisible && focusables.length > 0 && !gameInEditor;
if (!wants) return;
claimInput('keys');
return () => releaseInput('keys');
Expand Down Expand Up @@ -332,7 +344,7 @@
</script>

{#if anyVisible}
<div id="hud-layer" class="hud-layer" class:hud-authoring={!playing} data-authoring={!playing}>
<div id="hud-layer" class="hud-layer" class:hud-authoring={!playing} class:hud-inert={gameInEditor} data-authoring={!playing} data-inert={gameInEditor}>
{#each elements as el (el.__key + ':' + el.id)}
<div
class="hud-slot"
Expand All @@ -343,7 +355,7 @@
data-hud-kind={el.kind}
style="{place(el)}; {centering(el)}"
>
<HudElement element={el} runtime={$hudRuntime[el.id]} onpress={fireHudButton} />
<HudElement element={el} runtime={$hudRuntime[el.id]} editor={gameInEditor} onpress={fireHudButton} />
</div>
{/each}
</div>
Expand All @@ -368,6 +380,12 @@
.hud-slot {
position: absolute;
}
/* 30 P1: a previewed GAME screen is a picture of the menu, not the menu — every click
goes through it to the viewport (the elements opt back INTO pointer events one by
one, so the override has to reach them) */
.hud-inert :global(*) {
pointer-events: none !important;
}
/* the keyboard ring, so a player under pointer lock can see where they are */
.hud-focused {
outline: 2px solid var(--accent, #ef562f);
Expand Down
15 changes: 15 additions & 0 deletions src/components/menu/Controls.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
import { visibleDockKey, dockOccupants, bottomInset, FLOW_FAMILY, armDockMode, DOCK_TITLES } from '$lib/bottomDock';
import { togglePanel } from '$lib/panelToggles';
import { requestPlay, willEnterXR, willEnterAR, vrSupported, arSupported, xrSessionFailed } from '$lib/playMode';
// 30 P2: Test play — the play button's right-click row beside the game chip's button
import { testPlay } from '$lib/gamePresence';
import { hudIsGame } from '$lib/hudDocs';
import { DOCK_VIEWS } from '$lib/dockMenu';
import { safeStorage } from '$lib/safeStorage';
import { VRButton, XRButton } from '@threlte/xr'
Expand Down Expand Up @@ -916,6 +919,18 @@
requestPlay();
}
},
// 30 P2: TEST PLAY — back to the menu, into Play, the Start screen. Offered only
// where it means something: a scene with no game shell has no menu to start from.
...($hudIsGame
? [
{ section: 'Game' },
{
label: 'Test play (start from the menu)',
tooltip: 'Reset the game to its menu, enter Play, and start from the Start screen',
action: () => testPlay()
}
]
: []),
// the FAB is a toolbar cell like any other, so it carries the same tail —
// minus "Hide button" (there is no toolbar without a way to press play)
...toolbarTail(null)
Expand Down
6 changes: 5 additions & 1 deletion src/components/play/PlayReticle.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// playInteract.js.
import { isLocked, isVRMode } from '../../stores/sceneStore';
import { playInteractState } from '$lib/playInteract';
import { playCursorFree } from '$lib/playCursor';
import { safeStorage } from '$lib/safeStorage';

// the scroll hint is worth exactly one showing, so it is a LOCAL pref and
Expand All @@ -14,7 +15,10 @@
);

const reticle = $derived($playInteractState);
const visible = $derived($isLocked && !$isVRMode && reticle.mode !== 'off');
// 30 P3: a free-cursor game aims with the real cursor, so there is no crosshair to draw.
// $playInteractState is the dependency that re-reads it (it moves on every aim change,
// and playCursorFree reads its stores through get()).
const visible = $derived($isLocked && !$isVRMode && reticle.mode !== 'off' && !playCursorFree());
const carrying = $derived(reticle.mode === 'carrying');

$effect(() => {
Expand Down
9 changes: 8 additions & 1 deletion src/components/play/PointerLockControls.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import { userdata, peers } from '../../stores/appStore'
import { dungeonData, slideMove, spawnPointFor } from '$lib/dungeonPlay'
import { resolvePlaySettings } from '$lib/playSettings'
// 30 P3: free-cursor games never take the pointer — the real cursor aims
import { playCursorFree } from '$lib/playCursor'
import { inputClaims, getGamepadAxes } from '$lib/inputRuntime'
import { gamepadPrefs } from '$lib/gamepadPrefs'
import { coarsePointer } from '$lib/inputDevice'
Expand Down Expand Up @@ -111,7 +113,7 @@
if (document.pointerLockElement === domElement) document.exitPointerLock()
} else if (!free && wasFree) {
wasFree = false
if (!noPointerLock && $isLocked === true && document.pointerLockElement !== domElement) {
if (!noPointerLock && !playCursorFree() && $isLocked === true && document.pointerLockElement !== domElement) {
const again: any = domElement.requestPointerLock({ unadjustedMovement: true })
again?.catch?.(() => {})
}
Expand Down Expand Up @@ -160,6 +162,10 @@
*/
function requestLock(first: boolean) {
if (noPointerLock) return cancelLockRetry() // W4: nothing to lock on a touch device
// 30 P3: a FREE-CURSOR game (`play.cursor: 'free'`) enters play without a lock: the
// cursor stays visible and aims (playCursor.playAimNdc), movement keys still walk or
// fly, and Escape still leaves — PLC's Escape branch already handles "no lock held"
if (playCursorFree()) return cancelLockRetry()
if (get(isLocked) !== true) return cancelLockRetry()
if (get(playPointerFree)) return cancelLockRetry() // 21-E3: the menu owns the pointer
if (document.pointerLockElement === domElement) return cancelLockRetry()
Expand Down Expand Up @@ -527,6 +533,7 @@
// a programmatic exit, so this stays idle.)
function onCanvasPointerDown() {
if (noPointerLock) return // W4: a touch tap is the look/interact gesture, not a re-lock
if (playCursorFree()) return // 30 P3: a click in a free-cursor game is the game's
if ($isLocked === true && !$playPointerFree && document.pointerLockElement !== domElement) {
const again: any = domElement.requestPointerLock({ unadjustedMovement: true })
again?.catch?.(() => {})
Expand Down
9 changes: 7 additions & 2 deletions src/extensions/Grid.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
<script lang="ts">
import { Grid } from '@threlte/extras'
import { T, useThrelte, useTask } from '@threlte/core'
import { orbitControls } from '../stores/sceneStore'
import { orbitControls, isLocked } from '../stores/sceneStore'
// 30 P3: the grid is an EDITOR helper — it drew under/through the ground in five of the
// seven Games-tab games. Hidden while playing, on every scene; the existing "Show
// helpers in Play (debug)" toggle is the one way to keep it (no new setting).
import { helpersInPlay } from '../lib/helperLayer'
import { gridSettings, effectiveCell } from '../lib/gridSettings'
import { snapSettings } from '../lib/snapping'
let { showGrid } = $props()
Expand Down Expand Up @@ -71,8 +75,9 @@
const fadeDistance = $derived($gridSettings.fadeMode === 'auto' ? fade : $gridSettings.fadeDistance)
</script>

{#if showGrid}
{#if showGrid && ($isLocked !== true || $helpersInPlay)}
<Grid
name="editor-grid"
infiniteGrid={$gridSettings.infinite}
gridSize={$gridSettings.infinite ? undefined : [$gridSettings.size, $gridSettings.size]}
followCamera={$gridSettings.follow === 'camera'}
Expand Down
Loading
Loading