// Emotes (交流, research 09 §4): the 36 official in-match emotes of 盟约 — 6 themes × 6 (shared/constants.js // EMOTE_THEMES / EMOTES, generated reference data/emotes.json). Official emotes are pictures only: nothing here ever // renders an emote's text (our labels are aria-labels only). // // EmoteArt the emote picture (local-client art, data/local-assets.json → emoticon//); a neutral glyph // when the art is missing or fails to load, an empty box while the manifest is still loading. // EmoteBubble the pop bubble beside the sender's avatar in the team panel (official emoji_bubble_bkg: a dark rounded // square with a tail pointing left + the icon only); pop-in, 3 s, fade. The parent keys it by the emote's // seq so a newer emote replaces the old one and pops again, and passes the arrival time (`at`) so a // bubble mounted late resumes its timeline instead of restarting it. // EmoteWheel bottom-left 交流 button (official emoji_btn / emoji_btn_disable) + panel (emoji_bkg): one page per theme // with 3×2 cells (emoji_cell_bkg), pager dots, swipe / drag / arrow keys / wheel to change the page (the // wheel turns one page per gesture, a plain vertical mouse wheel included, like Unity's ScrollRect). The // panel reopens on the theme of the last emote sent (localStorage). Sending closes the panel and greys // the button for the 1 s cooldown (chatCD), counted from the send so a remount keeps it. // Official UI sprites come from the local ui/battle extraction when present; CSS draws look-alikes otherwise. // Styles: public/css/emotes.css (injected on first use when the page does not link it). import { useEffect, useRef, useState } from '../../vendor/hooks.module.js'; import { EMOTE_THEMES, EMOTE_COOLDOWN_MS, EMOTE_BUBBLE_MS, emoteInfo, emoteArtGroup } from '../../../shared/constants.js'; import { html } from './components.js'; import { GIcon } from './gameComponents.js'; import { data, useData, localAsset } from '../data.js'; import { loadPref, savePref } from '../store.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); export const EMOTE_CSS_HREF = '/css/emotes.css'; const PREF_THEME = 'emoteTheme'; const SWIPE_PX = 40; // horizontal drag distance that turns the page const DRAG_SLOP_PX = 8; // below this a press is a tap, not a drag export const WHEEL_STEP_PX = 60; // wheel / trackpad scroll that turns the page export const WHEEL_GAP_MS = 180; // a pause this long ends a wheel gesture (a trackpad's momentum tail included) // Time of the last send, shared by every wheel on the page: the 1 s cooldown survives a remount of the wheel (the // server enforces chatCD too and silently drops an emote sent early). let lastSentAt = -Infinity; /** Link public/css/emotes.css once when the host page does not (dev harnesses); no-op outside a browser. */ export function ensureEmoteCss(doc = globalThis.document) { if (!doc?.head || typeof doc.querySelector !== 'function') return false; if (doc.querySelector(`link[rel="stylesheet"][href$="${EMOTE_CSS_HREF}"]`)) return false; const link = doc.createElement('link'); link.rel = 'stylesheet'; link.href = EMOTE_CSS_HREF; doc.head.appendChild(link); return true; } /** * URL of an emote's official art, or null when the id is unknown or the local manifest does not list the picture * (a path that is not listed is never requested). * @param {string} id official emoji id */ export function emoteArtUrl(id) { const e = emoteInfo(id); return e ? localAsset(emoteArtGroup(id), e.picId) : null; } /** Official emote UI sprite (ui/battle: emoji_bubble_bkg, emoji_bkg, emoji_cell_bkg, emoji_btn, emoji_btn_disable). */ export const emoteUiSprite = (name) => localAsset('ui/battle', name); /** Index of a theme in the wheel (0 when unknown). */ export function themeIndex(themeId) { const i = EMOTE_THEMES.findIndex((t) => t.themeId === themeId); return i < 0 ? 0 : i; } /** Wheel page to open on: the theme of the last emote sent (remembered in localStorage), else the first. */ export function lastThemeIndex() { const v = loadPref(PREF_THEME, null); return typeof v === 'string' ? themeIndex(v) : 0; } /** Remember the theme of a sent emote. */ export function rememberTheme(themeId) { if (EMOTE_THEMES.some((t) => t.themeId === themeId)) savePref(PREF_THEME, themeId); } /** Clamp a page index into the wheel. */ export const clampPage = (i) => Math.max(0, Math.min(EMOTE_THEMES.length - 1, Math.trunc(Number(i)) || 0)); /** * Page change for a finished horizontal drag / swipe: -1 (previous), +1 (next) or 0. * @param {number} dx pointer travel in px (negative = leftwards = next page) * @param {number} [threshold] */ export const swipeStep = (dx, threshold = SWIPE_PX) => (dx <= -threshold ? 1 : dx >= threshold ? -1 : 0); /** * Scroll distance of a wheel event along the pager, in px. Like Unity's horizontal-only ScrollRect, a mostly vertical * wheel (a plain mouse wheel) drives the horizontal pager too: down / right = next page. * @param {{ deltaX?: number, deltaY?: number, deltaMode?: number }} e * @param {number} [pagePx] px per "page" delta (deltaMode 2) */ export function wheelDelta(e, pagePx = 300) { const x = Number(e?.deltaX) || 0; const y = Number(e?.deltaY) || 0; const d = Math.abs(x) >= Math.abs(y) ? x : y; const unit = e?.deltaMode === 1 ? 16 : e?.deltaMode === 2 ? pagePx : 1; // lines / pages → px return d * unit; } /** * Wheel / trackpad paging, at most one page per gesture. A gesture is a run of wheel events less than WHEEL_GAP_MS * apart; once it has turned the page the rest of it (the momentum tail of a trackpad swipe) is ignored, so one swipe * never skips several themes. * @param {{ x: number, t: number, spent: boolean }} acc gesture state (mutated) * @param {number} d scroll distance of this event (wheelDelta) * @param {number} now event time (ms) * @returns {-1|0|1} page change */ export function wheelStep(acc, d, now) { if (!(now - acc.t < WHEEL_GAP_MS) || now < acc.t) { acc.x = 0; acc.spent = false; } // a new gesture acc.t = now; if (acc.spent || !d) return 0; acc.x += d; if (Math.abs(acc.x) < WHEEL_STEP_PX) return 0; acc.spent = true; return acc.x > 0 ? 1 : -1; } /** * Cooldown left (ms) of the 交流 button after a send at `sentAt` (chatCD). * @param {number} sentAt * @param {number} now * @param {number} [cooldownMs] */ export function cooldownLeft(sentAt, now, cooldownMs = EMOTE_COOLDOWN_MS) { const left = sentAt + cooldownMs - now; return left > 0 && left <= cooldownMs ? left : 0; // a clock that went backwards never locks the button } /** * Age (ms) of a bubble received at `at` — so a bubble mounted late (the team panel re-rendered from scratch) resumes * its animation instead of popping in again for a full chatTime. 0 when unknown. * @param {number|undefined} at receipt time (Date.now() clock) * @param {number} now * @param {number} ttl */ export function bubbleAge(at, now, ttl = EMOTE_BUBBLE_MS) { const age = Number.isFinite(at) && Number.isFinite(now) ? now - at : 0; return age > 0 ? Math.min(age, Math.max(0, ttl)) : 0; } /** Neutral stand-in for a missing emote picture (a glyph, never text). */ function EmoteGlyph({ class: cls }) { return html``; } /** * Official emote picture; neutral glyph when unavailable. * @param {{ id: string, class?: string }} props */ export function EmoteArt({ id, class: cls }) { useData('local'); const [bad, setBad] = useState(null); const src = emoteArtUrl(id); if (src && bad !== src) { return html` setBad(src)} />`; } const st = data.status('local'); if (!src && emoteInfo(id) && (st === 'loading' || st === 'idle')) return html``; return html`<${EmoteGlyph} class=${cls} />`; } /** * Pop bubble (picture only) beside the sender's avatar. Unknown ids show the neutral glyph. Key it by the emote's seq * (a newer emote replaces the bubble and pops again). * @param {{ id: string, class?: string, ttl?: number, at?: number }} props ttl = display time in ms (fade-out at the * end); at = when the emote arrived (Date.now() clock): a bubble mounted after that resumes its pop / fade timeline * instead of restarting it. */ export function EmoteBubble({ id, class: cls, ttl = EMOTE_BUBBLE_MS, at }) { useData('local'); useEffect(() => { ensureEmoteCss(); }, []); const life = Math.max(300, Number(ttl) || EMOTE_BUBBLE_MS); const [age] = useState(() => bubbleAge(at, Date.now(), life)); // fixed at mount: the CSS animation runs from there const bg = emoteUiSprite('emoji_bubble_bkg'); const e = emoteInfo(id); const style = [`--ebubble-ttl:${life}ms`, age && `--ebubble-age:${Math.round(age)}ms`, bg && `--ebubble-bg:url("${bg}")`].filter(Boolean).join(';'); return html``; } /** * 交流 button + emote panel. * @param {{ onSend: (id:string)=>void, open: boolean, onToggle: (open:boolean)=>void, disabled?: boolean, * cooldownMs?: number }} props */ export function EmoteWheel({ onSend, open, onToggle, disabled = false, cooldownMs = EMOTE_COOLDOWN_MS }) { useData('local'); useEffect(() => { ensureEmoteCss(); }, []); const [page, setPage] = useState(lastThemeIndex); const [dir, setDir] = useState(0); // direction of the last page change (slide-in animation) const [dx, setDx] = useState(0); // live drag offset (px) const [cooling, setCooling] = useState(() => cooldownLeft(lastSentAt, Date.now(), cooldownMs) > 0); const drag = useRef(null); // { id, x0, moved } const swallowClick = useRef(false); const wheelAcc = useRef({ x: 0, t: -Infinity, spent: false }); const live = useRef({}); live.current = { page, onToggle }; const go = (to) => { const next = clampPage(to); if (next === live.current.page) return; setDir(next > live.current.page ? 1 : -1); setPage(next); live.current.page = next; // several key presses before the next render still step one page each }; // reopen on the last used theme useEffect(() => { if (open) { setPage(lastThemeIndex()); setDir(0); setDx(0); } }, [open]); // cooldown: the button is greyed for chatCD after a send (from the send time, so a remounted wheel keeps it) useEffect(() => { if (!cooling) return undefined; const t = setTimeout(() => setCooling(false), cooldownLeft(lastSentAt, Date.now(), cooldownMs)); return () => clearTimeout(t); }, [cooling]); // outside press closes; ← / → change the page useEffect(() => { if (!open) return undefined; const onDown = (e) => { if (!(e.target instanceof Element) || !e.target.closest('.ewheel')) live.current.onToggle(false); }; const onKey = (e) => { if (e.ctrlKey || e.metaKey || e.altKey) return; const t = e.target; if (t instanceof Element && t.closest('input, textarea, select, [contenteditable="true"]')) return; if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); go(live.current.page + (e.key === 'ArrowRight' ? 1 : -1)); } }; window.addEventListener('pointerdown', onDown, true); window.addEventListener('keydown', onKey); return () => { window.removeEventListener('pointerdown', onDown, true); window.removeEventListener('keydown', onKey); }; }, [open]); const send = (id) => { if (swallowClick.current) { swallowClick.current = false; return; } const e = emoteInfo(id); const now = Date.now(); if (!e || cooling || disabled || cooldownLeft(lastSentAt, now, cooldownMs) > 0) return; lastSentAt = now; rememberTheme(e.themeId); setCooling(true); onSend(id); onToggle(false); }; // swipe / drag (touch, pen and mouse) const onPointerDown = (e) => { if (e.button != null && e.button !== 0) return; drag.current = { id: e.pointerId, x0: e.clientX, moved: false }; swallowClick.current = false; }; const onPointerMove = (e) => { const d = drag.current; if (!d || d.id !== e.pointerId) return; if (e.pointerType === 'mouse' && !(e.buttons & 1)) { drag.current = null; setDx(0); return; } // released outside const off = e.clientX - d.x0; if (!d.moved && Math.abs(off) < DRAG_SLOP_PX) return; if (!d.moved) { d.moved = true; try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* ignore */ } } const edge = (off > 0 && live.current.page === 0) || (off < 0 && live.current.page === EMOTE_THEMES.length - 1); setDx(edge ? off / 3 : off); // rubber band at the first / last page }; const endDrag = (e, cancelled) => { const d = drag.current; if (!d || d.id !== e.pointerId) return; drag.current = null; if (!d.moved) return; swallowClick.current = true; // the click that follows a drag is not a send setTimeout(() => { swallowClick.current = false; }, 0); setDx(0); if (!cancelled) { const step = swipeStep(e.clientX - d.x0); if (step) go(live.current.page + step); } }; const onWheel = (e) => { const d = wheelDelta(e, e.currentTarget?.clientWidth || 300); if (!d) return; e.preventDefault(); const step = wheelStep(wheelAcc.current, d, performance.now()); if (step) go(live.current.page + step); }; const theme = EMOTE_THEMES[clampPage(page)]; const btnSprite = emoteUiSprite(disabled || cooling ? 'emoji_btn_disable' : 'emoji_btn'); const panelBg = emoteUiSprite('emoji_bkg'); const cellBg = emoteUiSprite('emoji_cell_bkg'); const panelStyle = [panelBg && `--ewheel-bg:url("${panelBg}")`, cellBg && `--ewheel-cell:url("${cellBg}")`].filter(Boolean).join(';'); return html`
${open ? html`` : null}
`; }