diff --git a/client/src/lib/components/EmotePanel.svelte b/client/src/lib/components/EmotePanel.svelte index fefcbff9..753cea61 100644 --- a/client/src/lib/components/EmotePanel.svelte +++ b/client/src/lib/components/EmotePanel.svelte @@ -4,13 +4,90 @@ emoteStopRequest, localEmoteAnim, } from '../stores/emoteStore' - import { EMOTE_LIST, type EmoteMeta } from '../emote-meta' + import { + EMOTE_LIST, + emoteClickCommand, + type EmoteIntent, + type EmoteMeta, + } from '../emote-meta' import { gameStore } from '../stores/gameStore' import { networkManager } from '../network/socket' + import { innerWidth } from 'svelte/reactivity/window' import { draggablePanel } from '../actions/draggablePanel' + import { panelPositions } from '../stores/panelLayout' + import { EMOTE_PANEL_W, previewPlacement } from '../emote-preview-layout' + import EmotePreview from './EmotePreview.svelte' const visible = $derived($emotePanelVisible) const active = $derived($localEmoteAnim) + const previewPlayer = $derived($gameStore.currentPlayer) + + // Last pointed-at emote; kept playing after the pointer leaves so the + // preview never snaps back to an empty box. Cleared on close — the + // component itself stays mounted for the whole session. + let previewed = $state(null) + $effect(() => { + // Also cleared on death/disconnect, so the box doesn't pop back open + // by itself on respawn or reconnect. + if (!visible || !usable) previewed = null + // Re-evaluate the mount latch per open; deaths keep the device warm. + if (!visible) everFit = false + }) + + // The box hangs off the panel's side. Work out which side has room from + // the panel's position and the viewport; with room on neither (narrow + // phone viewports), don't mount the preview canvas at all. + const placement = $derived( + previewPlacement( + $panelPositions.emotes?.x ?? + (innerWidth.current ?? 0) - EMOTE_PANEL_W - 16, + innerWidth.current ?? 0 + ) + ) + + // The store position updates on drag release, so hide the box while the + // panel is mid-drag instead of letting it ride off-screen on a stale side. + // Mirrors draggablePanel's gesture test (left button, not a header button, + // 4px slop) so a plain click never blinks the preview. + let draggingPanel = $state(false) + let dragPointerId: number | null = null + function trackDragStart(node: HTMLElement) { + const down = (e: PointerEvent) => { + if (e.button !== 0 || dragPointerId !== null) return + if ((e.target as HTMLElement).closest('button')) return + dragPointerId = e.pointerId + const { clientX, clientY } = e + const move = (m: PointerEvent) => { + if (m.pointerId !== dragPointerId) return + const dx = m.clientX - clientX + const dy = m.clientY - clientY + if (dx * dx + dy * dy >= 16) draggingPanel = true + } + const up = (u: PointerEvent) => { + if (u.pointerId !== dragPointerId) return + dragPointerId = null + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + window.removeEventListener('pointercancel', up) + draggingPanel = false + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + window.addEventListener('pointercancel', up) + } + node.addEventListener('pointerdown', down) + return { destroy: () => node.removeEventListener('pointerdown', down) } + } + + // Mount the preview stack once a fitting position has existed, then keep + // it warm; visibility churn (resize, zoom, drags across the fit boundary) + // only nulls the anim instead of rebuilding the WebGPU canvas. + let everFit = $state(false) + $effect(() => { + // Reads `visible` so the latch re-evaluates on every reopen, not only + // when the placement inputs change. + if (visible && placement.fits) everFit = true + }) // sendChatMessage is a silent no-op while disconnected; grey out like the // chat input does instead of eating clicks. Dead players are greyed out // too — the server accepts a corpse's /emote, so the client must not @@ -19,21 +96,32 @@ $gameStore.isConnected && ($gameStore.currentPlayer?.health ?? 0) > 0 ) + // Last commanded emote, kept until the server echo confirms it (or a TTL + // assumes rejection). See emoteClickCommand. + let intent = $state(null) + $effect(() => { + if (intent && active === intent.anim) intent = null + }) + function play(emote: EmoteMeta) { // Clicking the running looping emote stops it, like Escape does. - if (emote.loops && active === emote.anim) { + if ( + emoteClickCommand(emote, active, intent, performance.now()) === 'stop' + ) { emoteStopRequest.set(true) + intent = { anim: null, at: performance.now() } return } // Same path as typing the command: the server validates and its // broadcast starts our animation (see chat-commands `/emote`). networkManager.sendChatMessage(`/emote ${emote.anim}`) + intent = { anim: emote.anim, at: performance.now() } } {#if visible}
-
+
Emotes
/emote <name> · dances stop on move
+ + {#if previewPlayer && everFit} + + {/if}
{/if} @@ -69,7 +171,9 @@ .emote-panel { position: fixed; right: 16px; - top: 16%; + /* Above the HUD corner buttons, so it opens next to the social flyout + that summoned it. */ + bottom: 64px; z-index: 40; width: 180px; display: flex; diff --git a/client/src/lib/components/EmotePreview.svelte b/client/src/lib/components/EmotePreview.svelte new file mode 100644 index 00000000..7a92d9cf --- /dev/null +++ b/client/src/lib/components/EmotePreview.svelte @@ -0,0 +1,99 @@ + + + + + + diff --git a/client/src/lib/components/EmotePreviewScene.svelte b/client/src/lib/components/EmotePreviewScene.svelte new file mode 100644 index 00000000..32f73587 --- /dev/null +++ b/client/src/lib/components/EmotePreviewScene.svelte @@ -0,0 +1,157 @@ + + + cam.lookAt(0, CAMERA_LOOK_AT_Y, 0)} +/> + + + + +{#if modelRoot} + +{/if} diff --git a/client/src/lib/emote-meta.test.ts b/client/src/lib/emote-meta.test.ts index ca0c18c4..3613c6bf 100644 --- a/client/src/lib/emote-meta.test.ts +++ b/client/src/lib/emote-meta.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' import { readFileSync } from 'node:fs' import path from 'node:path' -import { EMOTE_LIST } from './emote-meta' +import { + EMOTE_INTENT_TTL_MS, + EMOTE_LIST, + emoteClickCommand, +} from './emote-meta' import { LOOPING_EMOTE_ANIMS, ONE_SHOT_EMOTE_ANIMS } from './stores/emoteStore' // The wire contract lives in shared/src/messages.rs; emoteStore mirrors it by @@ -43,3 +47,41 @@ describe('emote panel metadata', () => { } }) }) + +describe('emoteClickCommand', () => { + const dance = { anim: 'twist', label: 'Twist', loops: true } + const other = { anim: 'macarena', label: 'Macarena', loops: true } + const oneShot = { anim: 'clap', label: 'Clap', loops: false } + + it('toggles a settled looping emote to stop', () => { + expect(emoteClickCommand(dance, 'twist', null, 1000)).toBe('stop') + expect(emoteClickCommand(dance, null, null, 1000)).toBe('play') + }) + + it('plays, not stops, when a different emote was just commanded', () => { + // Server echo still says twist, but the player already clicked macarena. + const intent = { anim: other.anim, at: 900 } + expect(emoteClickCommand(dance, 'twist', intent, 1000)).toBe('play') + }) + + it('replays an emote the player just stopped despite the stale echo', () => { + const intent = { anim: null, at: 900 } + expect(emoteClickCommand(dance, 'twist', intent, 1000)).toBe('play') + }) + + it('stops a re-click of the emote just commanded', () => { + const intent = { anim: dance.anim, at: 900 } + expect(emoteClickCommand(dance, null, intent, 1000)).toBe('stop') + }) + + it('falls back to the server echo once the intent expires', () => { + const intent = { anim: other.anim, at: 0 } + expect( + emoteClickCommand(dance, 'twist', intent, EMOTE_INTENT_TTL_MS + 1) + ).toBe('stop') + }) + + it('never stops a one-shot emote', () => { + expect(emoteClickCommand(oneShot, 'clap', null, 1000)).toBe('play') + }) +}) diff --git a/client/src/lib/emote-meta.ts b/client/src/lib/emote-meta.ts index 60307a1d..4b1ec2e6 100644 --- a/client/src/lib/emote-meta.ts +++ b/client/src/lib/emote-meta.ts @@ -22,3 +22,27 @@ export const EMOTE_LIST: EmoteMeta[] = [...SLASH_EMOTE_ANIMS].map((anim) => ({ label: labelFor(anim), loops: LOOPING_EMOTE_ANIMS.has(anim), })) + +/** The player's last click — anim they commanded (null = stop) and when. */ +export interface EmoteIntent { + anim: string | null + at: number +} + +/** After this long an unconfirmed intent is assumed rejected by the server. */ +export const EMOTE_INTENT_TTL_MS = 2000 + +/** Whether clicking `emote` should stop or play, judged against the last + * commanded intent rather than the server echo — the echo lags a round + * trip, and deciding on stale state turns fast clicks into wrong stops + * (or dead re-stops) while an earlier command is still in flight. */ +export function emoteClickCommand( + emote: EmoteMeta, + active: string | null, + intent: EmoteIntent | null, + now: number +): 'stop' | 'play' { + const current = + intent && now - intent.at <= EMOTE_INTENT_TTL_MS ? intent.anim : active + return emote.loops && current === emote.anim ? 'stop' : 'play' +} diff --git a/client/src/lib/emote-preview-layout.test.ts b/client/src/lib/emote-preview-layout.test.ts new file mode 100644 index 00000000..ea1acf0e --- /dev/null +++ b/client/src/lib/emote-preview-layout.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { EMOTE_PANEL_W, previewPlacement } from './emote-preview-layout' + +/** The panel's default CSS position is `right: 16px`. */ +function defaultPanelLeft(viewportWidth: number): number { + return viewportWidth - EMOTE_PANEL_W - 16 +} + +describe('previewPlacement', () => { + it('hangs left of the default-placed panel on a desktop viewport', () => { + expect(previewPlacement(defaultPanelLeft(1920), 1920)).toEqual({ + side: 'left', + fits: true, + }) + }) + + it('does not render at all on a phone-width viewport', () => { + // 375px iPhone portrait: neither side of the default panel has room. + expect(previewPlacement(defaultPanelLeft(375), 375).fits).toBe(false) + // The default panel's left side gains room at exactly 415px. + expect(previewPlacement(defaultPanelLeft(414), 414).fits).toBe(false) + expect(previewPlacement(defaultPanelLeft(415), 415).fits).toBe(true) + }) + + it('flips right when the panel is dragged against the left edge', () => { + expect(previewPlacement(50, 1000)).toEqual({ side: 'right', fits: true }) + }) + + it('refuses the dead band where neither side fits a dragged panel', () => { + // 500px window, panel dragged to x=150: left needs 197, right needs 399. + expect(previewPlacement(150, 500).fits).toBe(false) + }) + + it('keeps the left side the moment it has room', () => { + expect(previewPlacement(197, 500)).toEqual({ side: 'left', fits: true }) + }) +}) diff --git a/client/src/lib/emote-preview-layout.ts b/client/src/lib/emote-preview-layout.ts new file mode 100644 index 00000000..875335bf --- /dev/null +++ b/client/src/lib/emote-preview-layout.ts @@ -0,0 +1,21 @@ +/** Border-box width of the emote panel (180 content + 20 padding + 2 border). */ +export const EMOTE_PANEL_W = 202 +/** Width the preview box needs beside the panel (188 border box + 9 offset). */ +export const EMOTE_PREVIEW_W = 197 + +export interface PreviewPlacement { + side: 'left' | 'right' + /** False when neither side has room — the preview must not render at all. */ + fits: boolean +} + +/** Which side of the emote panel the preview box fits on, if any. + * `panelLeft` is the panel's border-box left edge in viewport px. */ +export function previewPlacement( + panelLeft: number, + viewportWidth: number +): PreviewPlacement { + const fitsLeft = panelLeft >= EMOTE_PREVIEW_W + const fitsRight = panelLeft + EMOTE_PANEL_W + EMOTE_PREVIEW_W <= viewportWidth + return { side: fitsLeft ? 'left' : 'right', fits: fitsLeft || fitsRight } +} diff --git a/client/src/lib/utils/renderer.ts b/client/src/lib/utils/renderer.ts index 3346cabc..054651fa 100644 --- a/client/src/lib/utils/renderer.ts +++ b/client/src/lib/utils/renderer.ts @@ -2,6 +2,7 @@ import * as THREE from 'three' import { WebGPURenderer } from 'three/webgpu' import { applyInitialAntialias, + getAppliedAntialias, getCurrentPreset, } from '../stores/graphicsSettings' @@ -40,3 +41,26 @@ export function createWebGPURenderer(canvas: HTMLCanvasElement) { return renderer } + +/** Renderer for the small secondary preview canvases. Reuses the main + * renderer's applied antialias without re-recording it (that record is + * the main canvas's restart-notice source), and defers a dispose that + * races init() so the GPU device is actually released. The pixel-ratio + * cap must come from the Canvas `dpr` prop — Threlte overwrites anything + * set here. */ +export function createPreviewWebGPURenderer(canvas: HTMLCanvasElement) { + const renderer = new WebGPURenderer({ + canvas, + antialias: getAppliedAntialias(), + }) + + const _origDispose = renderer.dispose.bind(renderer) + renderer.dispose = () => { + renderer + .init() + .then(() => _origDispose()) + .catch(() => {}) + } + + return renderer +}