openvibers/openvibe.games Commits
The game follows the player's OpenVibe theme: HUD accents and buttons from the theme, text size scales the HUD, calm motion stills the weapon bob and sway and the hit wobble (#148)
Changes
@@@ -19,6 +19,7 @@ import { Avatar } from './avatar/avatar.js'import { buildPropLook } from './propLooks.js'import { buildCritter, npcAppearance } from './npcLooks.js'import { materialFor, meshForShape } from './sceneSetup.js'++import { motionScale } from '../ui/theme.js'/*** Maintains the renderable + collidable view of replicated entities:@@@ -353,7 +354,7 @@ export class EntityView {// Chop/mine feedback: brief wobble on the hit node.if (v.shakeT > 0 && v.mesh) {v.shakeT = Math.max(0, v.shakeT - dt)-- const k = v.shakeT / 0.3++ const k = (v.shakeT / 0.3) * motionScale()v.mesh.rotation.z = Math.sin(localTime * 55) * 0.045 * kv.mesh.rotation.x = Math.sin(localTime * 47 + 1.3) * 0.03 * k}@@@ -15,6 +15,7 @@ import { createHeldItemNode, type HeldItemNode } from './heldItem.js'import { outfitColor, skinTone } from './avatar/palettes.js'import { createTaperedBox } from './avatar/taperedBox.js'import { materialFor } from './sceneSetup.js'++import { motionScale } from '../ui/theme.js'/*** First-person viewmodel: the equipped tool rendered at the camera with@@@ -291,13 +292,14 @@ export class Viewmodel {this.equipT = Math.min(1, this.equipT + dt * 3.1)if (this.swingT > 0) this.swingT = Math.max(0, this.swingT - dt)-- // Sway: lag behind the view with exponential decay.-- this.swayYaw += (mouseDx * 0.6 - this.swayYaw) * Math.min(1, dt * 10)-- this.swayPitch += (mouseDy * 0.6 - this.swayPitch) * Math.min(1, dt * 10)++ // Sway: lag behind the view with exponential decay. Calm motion (the player's OpenVibe setting) keeps a fifth of it.++ const keep = motionScale()++ this.swayYaw += (mouseDx * 0.6 * keep - this.swayYaw) * Math.min(1, dt * 10)++ this.swayPitch += (mouseDy * 0.6 * keep - this.swayPitch) * Math.min(1, dt * 10)// Bob while moving on the ground.if (grounded && speed > 0.3) this.bobPhase += dt * Math.min(speed, 8) * 1.6-- const bobAmp = grounded ? Math.min(speed / 7.2, 1) * 0.012 : 0++ const bobAmp = grounded ? Math.min(speed / 7.2, 1) * 0.012 * keep : 0const bobY = Math.abs(Math.sin(this.bobPhase)) * -bobAmpconst bobX = Math.sin(this.bobPhase * 0.5) * bobAmp * 0.6@@@ -1,3 +1,24 @@++/* ── The player's OpenVibe theme ───────────────────────────────────++ The shared theme loader (theme-loader.js on every page) sets the++ theme's tokens on <html>; the HUD takes its accents and buttons from++ them, so the game follows the theme someone picked on any OpenVibe++ site (and the season's, in October and December). Without a theme,++ the game's own gold and steel blue. Panels stay dark glass over the++ world whatever the theme, so they read on any scene. The display++ preferences apply too: text size scales every font size here++ (calc(Npx * var(--ov-text-scale, 1))),++ calm motion stills its animations (html[data-ov-motion=reduced]);++ src/ui/theme.ts tells the engine the rest. */++:root {++ --g-accent: var(--accent, #f0b040);++ --g-accent-text: var(--accent-light, var(--accent, #f0b040));++ --g-accent-soft: color-mix(in srgb, var(--g-accent) 45%, transparent);++ --g-on-accent: var(--on-accent, #1a1206);++ --g-action: var(--accent-strong, var(--accent, #2a5a8a));++ --g-action-edge: var(--accent-light, #58a4c4);++ --g-on-action: var(--on-accent-strong, var(--on-accent, #fff));++}++* {margin: 0;padding: 0;@@@ -120,7 +141,7 @@ body.ovg-frame-hidden #ov-footer {background: rgba(0, 0, 0, 0.6);padding: 6px 14px;border-radius: 4px;-- font-size: 14px;++ font-size: calc(14px * var(--ov-text-scale, 1));display: none;}@@@ -143,7 +164,7 @@ body.ovg-frame-hidden #ov-footer {display: flex;align-items: center;justify-content: center;-- font-size: 11px;++ font-size: calc(11px * var(--ov-text-scale, 1));text-align: center;pointer-events: auto;cursor: pointer;@@@ -151,7 +172,7 @@ body.ovg-frame-hidden #ov-footer {}.slot.active {-- border-color: #f0b040;++ border-color: var(--g-accent-text);}.slot.holstered {@@@ -167,7 +188,7 @@ body.ovg-frame-hidden #ov-footer {position: absolute;right: 4px;bottom: 2px;-- font-size: 12px;++ font-size: calc(12px * var(--ov-text-scale, 1));font-weight: 700;color: #f0d090;}@@@ -176,7 +197,7 @@ body.ovg-frame-hidden #ov-footer {position: absolute;left: 4px;top: 2px;-- font-size: 10px;++ font-size: calc(10px * var(--ov-text-scale, 1));color: #8fa0b0;}@@@ -191,9 +212,9 @@ body.ovg-frame-hidden #ov-footer {}.panel h2 {-- font-size: 15px;++ font-size: calc(15px * var(--ov-text-scale, 1));margin-bottom: 10px;-- color: #f0b040;++ color: var(--g-accent-text);letter-spacing: 0.06em;text-transform: uppercase;}@@@ -235,11 +256,11 @@ body.ovg-frame-hidden #ov-footer {.recipe .name {font-weight: 600;-- font-size: 14px;++ font-size: calc(14px * var(--ov-text-scale, 1));}.recipe .req {-- font-size: 12px;++ font-size: calc(12px * var(--ov-text-scale, 1));color: #9fb0c0;margin: 4px 0;}@@@ -249,13 +270,13 @@ body.ovg-frame-hidden #ov-footer {}.recipe button {-- background: #2a5a8a;-- color: #fff;++ background: var(--g-action);++ color: var(--g-on-action);border: 0;border-radius: 4px;padding: 5px 12px;cursor: pointer;-- font-size: 13px;++ font-size: calc(13px * var(--ov-text-scale, 1));}.recipe button:disabled {@@@ -277,10 +298,10 @@ body.ovg-frame-hidden #ov-footer {.toast {background: rgba(0, 0, 0, 0.7);-- border-left: 3px solid #f0b040;++ border-left: 3px solid var(--g-accent);padding: 6px 14px;border-radius: 4px;-- font-size: 13px;++ font-size: calc(13px * var(--ov-text-scale, 1));animation: fadeout 3.5s forwards;}@@@ -304,7 +325,7 @@ body.ovg-frame-hidden #ov-footer {position: absolute;top: 10px;left: 12px;-- font-size: 12px;++ font-size: calc(12px * var(--ov-text-scale, 1));color: #8fa0b0;}/* In the world the folded Frame's OpenVibe pill sits in that corner: the status line goes under it. */@@@ -325,9 +346,9 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {}.overlay-center h1 {-- font-size: 42px;++ font-size: calc(42px * var(--ov-text-scale, 1));letter-spacing: 0.12em;-- color: #f0b040;++ color: var(--g-accent-text);}.overlay-center input {@@@ -336,15 +357,15 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {border: 1px solid #3a4450;border-radius: 6px;padding: 10px 16px;-- font-size: 16px;++ font-size: calc(16px * var(--ov-text-scale, 1));text-align: center;}.overlay-center button {-- background: #2a5a8a;++ background: var(--g-action);border: 0;-- color: #fff;-- font-size: 16px;++ color: var(--g-on-action);++ font-size: calc(16px * var(--ov-text-scale, 1));padding: 10px 30px;border-radius: 6px;cursor: pointer;@@@ -352,7 +373,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {.overlay-center .hint {color: #8fa0b0;-- font-size: 13px;++ font-size: calc(13px * var(--ov-text-scale, 1));}#skills-panel {@@@ -369,12 +390,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {.skill-head {display: flex;justify-content: space-between;-- font-size: 14px;++ font-size: calc(14px * var(--ov-text-scale, 1));margin-bottom: 4px;}.skill-level {-- color: #f0b040;++ color: var(--g-accent-text);font-weight: 700;}@@@ -387,11 +408,11 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {.skill-fill {height: 100%;-- background: linear-gradient(90deg, #2a5a8a, #58a4c4);++ background: linear-gradient(90deg, var(--g-action), var(--g-action-edge));}.skill-xp {-- font-size: 11px;++ font-size: calc(11px * var(--ov-text-scale, 1));color: #8fa0b0;margin-top: 2px;}@@@ -404,7 +425,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {}.hint-line {-- font-size: 12px;++ font-size: calc(12px * var(--ov-text-scale, 1));color: #8fa0b0;margin-bottom: 10px;}@@@ -415,17 +436,17 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {align-items: center;padding: 6px 0;border-bottom: 1px solid #232b33;-- font-size: 14px;++ font-size: calc(14px * var(--ov-text-scale, 1));}.trust-btn {-- background: #2a5a8a;-- color: #fff;++ background: var(--g-action);++ color: var(--g-on-action);border: 0;border-radius: 4px;padding: 4px 12px;cursor: pointer;-- font-size: 12px;++ font-size: calc(12px * var(--ov-text-scale, 1));}.trust-btn.trusted {@@@ -453,9 +474,9 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {}.customize-panel h1 {-- font-size: 26px;++ font-size: calc(26px * var(--ov-text-scale, 1));letter-spacing: 0.12em;-- color: #f0b040;++ color: var(--g-accent-text);margin-bottom: 12px;}@@@ -467,12 +488,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {border: 1px solid #3a4450;border-radius: 6px;padding: 8px 12px;-- font-size: 15px;++ font-size: calc(15px * var(--ov-text-scale, 1));margin-bottom: 10px;}.cust-label {-- font-size: 11px;++ font-size: calc(11px * var(--ov-text-scale, 1));text-transform: uppercase;letter-spacing: 0.08em;color: #8fa0b0;@@@ -495,7 +516,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {}.swatch.selected {-- border-color: #f0b040;++ border-color: var(--g-accent-text);}.cust-btn {@@@ -505,12 +526,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {border-radius: 5px;padding: 5px 12px;cursor: pointer;-- font-size: 13px;++ font-size: calc(13px * var(--ov-text-scale, 1));}.cust-btn.selected {-- background: #2a5a8a;-- border-color: #58a4c4;++ background: var(--g-action);++ border-color: var(--g-action-edge);}.cust-sliders {@@@ -521,7 +542,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {}.cust-sliders label {-- font-size: 12px;++ font-size: calc(12px * var(--ov-text-scale, 1));color: #9fb0c0;display: flex;justify-content: space-between;@@@ -547,12 +568,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {border-radius: 6px;padding: 10px 0;cursor: pointer;-- font-size: 14px;++ font-size: calc(14px * var(--ov-text-scale, 1));}.cust-actions button.primary {-- background: #2a5a8a;-- border-color: #58a4c4;++ background: var(--g-action);++ border-color: var(--g-action-edge);font-weight: 600;}@@@ -590,7 +611,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {color: #8fa0b0;padding: 11px 10px;white-space: nowrap;-- font-size: 13px;++ font-size: calc(13px * var(--ov-text-scale, 1));letter-spacing: 0.05em;cursor: pointer;border-bottom: 2px solid transparent;@@@ -604,8 +625,8 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {}.menu-tab.active {-- color: #f0b040;-- border-bottom-color: #f0b040;++ color: var(--g-accent-text);++ border-bottom-color: var(--g-accent-text);}.menu-body {@@@ -675,13 +696,13 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {.craft-btn {flex: none;-- background: #2a5a8a;-- color: #fff;++ background: var(--g-action);++ color: var(--g-on-action);border: 0;border-radius: 6px;padding: 8px 14px;cursor: pointer;-- font-size: 13px;++ font-size: calc(13px * var(--ov-text-scale, 1));}.craft-btn:disabled {@@@ -720,7 +741,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {border: 0;border-radius: 6px;color: #8fa0b0;-- font-size: 13px;++ font-size: calc(13px * var(--ov-text-scale, 1));letter-spacing: 0.03em;cursor: pointer;transition:@@@ -734,7 +755,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {.segmented-option.active {background: #2a333d;-- color: #f0b040;++ color: var(--g-accent-text);}/* The first-steps waypoint: an arrow and a distance at the top centre, toward the current step's target. */@@@ -748,10 +769,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {gap: 8px;padding: 5px 12px 5px 8px;border-radius: 999px;-- border: 1px solid rgba(240, 176, 64, 0.45);++ border: 1px solid var(--g-accent-soft);background: rgba(14, 18, 24, 0.78);color: #f3e6c8;-- font: 600 13px/1.2 system-ui, sans-serif;++ font:++ 600 calc(13px * var(--ov-text-scale, 1))/1.2 system-ui,++ sans-serif;pointer-events: none;white-space: nowrap;}@@@ -765,9 +788,9 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {width: 22px;height: 22px;border-radius: 50%;-- background: #f0b040;-- color: #1a1206;-- font-size: 15px;++ background: var(--g-accent);++ color: var(--g-on-accent);++ font-size: calc(15px * var(--ov-text-scale, 1));font-weight: 800;transition: transform 0.15s linear;}@@@ -778,17 +801,17 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {width: 100%;min-height: 40px;margin: 2px 0 6px;-- accent-color: #f0b040;++ accent-color: var(--g-accent-text);}.segmented-option:focus-visible {-- outline: 2px solid #f0b040;++ outline: 2px solid var(--g-accent);outline-offset: 1px;}.weapon-title {-- font-size: 15px;-- color: #f0b040;++ font-size: calc(15px * var(--ov-text-scale, 1));++ color: var(--g-accent-text);letter-spacing: 0.06em;text-transform: uppercase;margin-bottom: 10px;@@@ -799,7 +822,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {}.weapon-opt-label {-- font-size: 12px;++ font-size: calc(12px * var(--ov-text-scale, 1));color: #9fb0c0;margin-bottom: 5px;}@@@ -833,7 +856,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {min-height: 0;}.status-chip {-- font-size: 10px;++ font-size: calc(10px * var(--ov-text-scale, 1));letter-spacing: 0.04em;color: #e8edf2;background: rgba(10, 14, 18, 0.6);@@@ -844,7 +867,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {}.vital span {width: 38px;-- font-size: 10px;++ font-size: calc(10px * var(--ov-text-scale, 1));letter-spacing: 0.08em;color: #cdd3da;text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);@@@ -983,7 +1006,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {font-weight: 600;}.shop-cost {-- font-size: 12px;++ font-size: calc(12px * var(--ov-text-scale, 1));color: #9ab08e;}.shop-cost.missing {@@@ -999,7 +1022,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {color: #f0d79a;border-radius: 10px;padding: 10px 22px;-- font-size: 16px;++ font-size: calc(16px * var(--ov-text-scale, 1));letter-spacing: 0.03em;opacity: 0;transition: opacity 0.4s ease;@@@ -1020,7 +1043,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {background: rgba(20, 26, 34, 0.86);border: 1px solid rgba(232, 181, 74, 0.55);color: #e8e2d4;-- font-size: 13px;++ font-size: calc(13px * var(--ov-text-scale, 1));line-height: 1.35;pointer-events: auto;}@@@ -1035,14 +1058,14 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {font-weight: 700;letter-spacing: 0.04em;text-transform: uppercase;-- font-size: 11px;++ font-size: calc(11px * var(--ov-text-scale, 1));color: #e8b54a;}.first-steps-head button {background: none;border: 0;color: #b9b2a3;-- font-size: 18px;++ font-size: calc(18px * var(--ov-text-scale, 1));line-height: 1;cursor: pointer;padding: 0 2px;@@@ -1081,13 +1104,13 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {margin: 2px 0 4px 1.2em;font-weight: 400;color: #cfc8b8;-- font-size: 12px;++ font-size: calc(12px * var(--ov-text-scale, 1));}@media (max-width: 640px) {.first-steps {top: auto;bottom: 96px;-- font-size: 12px;++ font-size: calc(12px * var(--ov-text-scale, 1));}}@@@ -1117,14 +1140,14 @@ body:has(.ov-touch:not([hidden])) .status {}body:has(.ov-touch:not([hidden])) .waypoint {top: 52px;-- font-size: 12px;++ font-size: calc(12px * var(--ov-text-scale, 1));}body:has(.ov-touch:not([hidden])) .first-steps {top: 92px;bottom: auto;right: 8px;width: min(250px, calc(100vw - 16px));-- font-size: 12px;++ font-size: calc(12px * var(--ov-text-scale, 1));}/* Only the step in progress: the list would cover a phone's view. */body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {@@@ -1149,7 +1172,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {opacity: 1;}.death-title {-- font-size: 64px;++ font-size: calc(64px * var(--ov-text-scale, 1));font-weight: 800;letter-spacing: 0.35em;color: #c33;@@@ -1167,11 +1190,11 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {width: 100%;padding: 14px 16px;margin-top: 8px;-- font-size: 15px;++ font-size: calc(15px * var(--ov-text-scale, 1));}.char-slot span {color: #78828e;-- font-size: 12px;++ font-size: calc(12px * var(--ov-text-scale, 1));}.char-locked {@@@ -1184,7 +1207,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {background: rgba(40, 60, 86, 0.55);border: 1px solid #34506e;border-radius: 10px;-- font-size: 13px;++ font-size: calc(13px * var(--ov-text-scale, 1));color: #b8c6d6;line-height: 1.5;}@@@ -1197,7 +1220,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {width: 100%;margin-top: 10px;padding: 13px 16px;-- font-size: 15px;++ font-size: calc(15px * var(--ov-text-scale, 1));font-weight: 700;background: linear-gradient(180deg, #3b6a96, #2a4d70);border-color: #4b7cab;@@@ -1212,7 +1235,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {background: rgba(60, 50, 25, 0.5);border: 1px solid #6e5a2e;border-radius: 8px;-- font-size: 12.5px;++ font-size: calc(12.5px * var(--ov-text-scale, 1));color: #d8c9a0;line-height: 1.45;}@@@ -1231,7 +1254,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {border: 1px solid #ffce54;border-radius: 8px;color: #fff3d6;-- font-size: 13px;++ font-size: calc(13px * var(--ov-text-scale, 1));font-weight: 700;letter-spacing: 0.04em;pointer-events: none;@@@ -1257,12 +1280,12 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {background: rgba(30, 70, 45, 0.45);border: 1px solid #3f7a55;border-radius: 8px;-- font-size: 13px;++ font-size: calc(13px * var(--ov-text-scale, 1));color: #a8dcb8;}.char-logout {padding: 5px 12px;-- font-size: 12px;++ font-size: calc(12px * var(--ov-text-scale, 1));background: rgba(90, 40, 40, 0.6);border: 1px solid #8a4a4a;border-radius: 6px;nnew file mode 100644@@@ -0,0 +1,35 @@++// @vitest-environment jsdom++import { afterEach, describe, expect, it } from 'vitest'++import { _resetTheme, calm, motionScale, onCalmChange, themeColor } from './theme.js'++++const flush = () => new Promise((r) => setTimeout(r, 0))++++describe("the player's OpenVibe display preferences in the engine", () => {++ afterEach(() => {++ document.documentElement.removeAttribute('data-ov-motion')++ document.documentElement.style.removeProperty('--accent')++ _resetTheme()++ })++++ it('keeps all the motion unless calm motion is on, and follows it as it changes', async () => {++ expect(calm()).toBe(false)++ expect(motionScale()).toBe(1)++ const seen: boolean[] = []++ onCalmChange((c) => seen.push(c))++ document.documentElement.setAttribute('data-ov-motion', 'reduced') // the theme loader, after a change on any site++ await flush()++ expect(calm()).toBe(true)++ expect(motionScale()).toBe(0.2)++ window.dispatchEvent(new Event('ov:display'))++ document.documentElement.removeAttribute('data-ov-motion')++ await flush()++ expect(motionScale()).toBe(1)++ expect(seen).toEqual([true, false])++ })++++ it("reads the theme's colours for canvas-drawn UI, with a fallback", () => {++ expect(themeColor('--accent', '#f0b040')).toBe('#f0b040')++ document.documentElement.style.setProperty('--accent', '#ff7a1a')++ expect(themeColor('--accent', '#f0b040')).toBe('#ff7a1a')++ })++})nnew file mode 100644@@@ -0,0 +1,85 @@++/**++ * The player's OpenVibe display preferences in the engine.++ *++ * Every page runs the shared theme loader (theme-loader.js), which paints the theme someone picked on any OpenVibe site++ * onto <html> (the HUD's CSS reads those tokens: styles.css :root --g-*) and keeps their display preferences: text size++ * (--ov-text-scale, also read by the CSS) and calm motion (html[data-ov-motion=reduced]). This module is what CSS cannot++ * reach: how much of the engine's own decorative motion to keep (the weapon's bob and sway, the hit wobble), current as++ * the person changes it in another tab or on another site, and the theme's colours for anything drawn on a canvas.++ */++++let calmNow = false++let started = false++const listeners = new Set<(calm: boolean) => void>()++++function read(): boolean {++ if (typeof document === 'undefined') return false++ if (document.documentElement.getAttribute('data-ov-motion') === 'reduced') return true++ try {++ return (++ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches++ )++ } catch {++ return false++ }++}++++function refresh(): void {++ const c = read()++ if (c === calmNow) return++ calmNow = c++ listeners.forEach((fn) => fn(c))++}++++function start(): void {++ if (started) return++ started = true++ calmNow = read()++ if (typeof window === 'undefined') return++ window.addEventListener('ov:display', refresh)++ try {++ matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', refresh)++ } catch {++ /* no media queries */++ }++ try {++ new MutationObserver(refresh).observe(document.documentElement, {++ attributes: true,++ attributeFilter: ['data-ov-motion'],++ })++ } catch {++ /* no DOM */++ }++}++++/** Calm motion is on (the OpenVibe setting or the system's reduced-motion setting). */++export function calm(): boolean {++ start()++ return calmNow++}++++/** How much of the engine's decorative motion to keep: 1, or 0.2 when calm. */++export function motionScale(): number {++ return calm() ? 0.2 : 1++}++++/** Called with the new value whenever calm motion turns on or off; returns a function that stops it. */++export function onCalmChange(fn: (calm: boolean) => void): () => void {++ start()++ listeners.add(fn)++ return () => listeners.delete(fn)++}++++/** One of the theme's colours (a CSS custom property on <html>, like '--accent'), or the fallback. */++export function themeColor(name: string, fallback: string): string {++ if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') return fallback++ const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()++ return v || fallback++}++++/** Tests only: forget what was read. */++export function _resetTheme(): void {++ started = false++ calmNow = false++ listeners.clear()++}nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/client/src/ui/theme.test.ts'++// covers: 'apps/client/src/ui'++// Generated by node test/run.js --index; do not edit.
@@ -19,6 +19,7 @@ import { Avatar } from './avatar/avatar.js'
@@ -19,6 +19,7 @@ import { Avatar } from './avatar/avatar.js'
import { buildPropLook } from './propLooks.js'
import { buildPropLook } from './propLooks.js'
import { buildCritter, npcAppearance } from './npcLooks.js'
import { buildCritter, npcAppearance } from './npcLooks.js'
import { materialFor, meshForShape } from './sceneSetup.js'
import { materialFor, meshForShape } from './sceneSetup.js'
+import { motionScale } from '../ui/theme.js'
/**
/**
* Maintains the renderable + collidable view of replicated entities:
* Maintains the renderable + collidable view of replicated entities:
@@ -353,7 +354,7 @@ export class EntityView {
@@ -353,7 +354,7 @@ export class EntityView {
// Chop/mine feedback: brief wobble on the hit node.
// Chop/mine feedback: brief wobble on the hit node.
if (v.shakeT > 0 && v.mesh) {
if (v.shakeT > 0 && v.mesh) {
v.shakeT = Math.max(0, v.shakeT - dt)
v.shakeT = Math.max(0, v.shakeT - dt)
- const k = v.shakeT / 0.3
+ const k = (v.shakeT / 0.3) * motionScale()
v.mesh.rotation.z = Math.sin(localTime * 55) * 0.045 * k
v.mesh.rotation.z = Math.sin(localTime * 55) * 0.045 * k
v.mesh.rotation.x = Math.sin(localTime * 47 + 1.3) * 0.03 * k
v.mesh.rotation.x = Math.sin(localTime * 47 + 1.3) * 0.03 * k
}
}
@@ -15,6 +15,7 @@ import { createHeldItemNode, type HeldItemNode } from './heldItem.js'
@@ -15,6 +15,7 @@ import { createHeldItemNode, type HeldItemNode } from './heldItem.js'
import { outfitColor, skinTone } from './avatar/palettes.js'
import { outfitColor, skinTone } from './avatar/palettes.js'
import { createTaperedBox } from './avatar/taperedBox.js'
import { createTaperedBox } from './avatar/taperedBox.js'
import { materialFor } from './sceneSetup.js'
import { materialFor } from './sceneSetup.js'
+import { motionScale } from '../ui/theme.js'
/**
/**
* First-person viewmodel: the equipped tool rendered at the camera with
* First-person viewmodel: the equipped tool rendered at the camera with
@@ -291,13 +292,14 @@ export class Viewmodel {
@@ -291,13 +292,14 @@ export class Viewmodel {
this.equipT = Math.min(1, this.equipT + dt * 3.1)
this.equipT = Math.min(1, this.equipT + dt * 3.1)
if (this.swingT > 0) this.swingT = Math.max(0, this.swingT - dt)
if (this.swingT > 0) this.swingT = Math.max(0, this.swingT - dt)
- // Sway: lag behind the view with exponential decay.
- this.swayYaw += (mouseDx * 0.6 - this.swayYaw) * Math.min(1, dt * 10)
- this.swayPitch += (mouseDy * 0.6 - this.swayPitch) * Math.min(1, dt * 10)
+ // Sway: lag behind the view with exponential decay. Calm motion (the player's OpenVibe setting) keeps a fifth of it.
+ const keep = motionScale()
+ this.swayYaw += (mouseDx * 0.6 * keep - this.swayYaw) * Math.min(1, dt * 10)
+ this.swayPitch += (mouseDy * 0.6 * keep - this.swayPitch) * Math.min(1, dt * 10)
// Bob while moving on the ground.
// Bob while moving on the ground.
if (grounded && speed > 0.3) this.bobPhase += dt * Math.min(speed, 8) * 1.6
if (grounded && speed > 0.3) this.bobPhase += dt * Math.min(speed, 8) * 1.6
- const bobAmp = grounded ? Math.min(speed / 7.2, 1) * 0.012 : 0
+ const bobAmp = grounded ? Math.min(speed / 7.2, 1) * 0.012 * keep : 0
const bobY = Math.abs(Math.sin(this.bobPhase)) * -bobAmp
const bobY = Math.abs(Math.sin(this.bobPhase)) * -bobAmp
const bobX = Math.sin(this.bobPhase * 0.5) * bobAmp * 0.6
const bobX = Math.sin(this.bobPhase * 0.5) * bobAmp * 0.6
@@ -1,3 +1,24 @@
@@ -1,3 +1,24 @@
+/* ── The player's OpenVibe theme ───────────────────────────────────
+ The shared theme loader (theme-loader.js on every page) sets the
+ theme's tokens on <html>; the HUD takes its accents and buttons from
+ them, so the game follows the theme someone picked on any OpenVibe
+ site (and the season's, in October and December). Without a theme,
+ the game's own gold and steel blue. Panels stay dark glass over the
+ world whatever the theme, so they read on any scene. The display
+ preferences apply too: text size scales every font size here
+ (calc(Npx * var(--ov-text-scale, 1))),
+ calm motion stills its animations (html[data-ov-motion=reduced]);
+ src/ui/theme.ts tells the engine the rest. */
+:root {
+ --g-accent: var(--accent, #f0b040);
+ --g-accent-text: var(--accent-light, var(--accent, #f0b040));
+ --g-accent-soft: color-mix(in srgb, var(--g-accent) 45%, transparent);
+ --g-on-accent: var(--on-accent, #1a1206);
+ --g-action: var(--accent-strong, var(--accent, #2a5a8a));
+ --g-action-edge: var(--accent-light, #58a4c4);
+ --g-on-action: var(--on-accent-strong, var(--on-accent, #fff));
+}
+
* {
* {
margin: 0;
margin: 0;
padding: 0;
padding: 0;
@@ -120,7 +141,7 @@ body.ovg-frame-hidden #ov-footer {
@@ -120,7 +141,7 @@ body.ovg-frame-hidden #ov-footer {
background: rgba(0, 0, 0, 0.6);
background: rgba(0, 0, 0, 0.6);
padding: 6px 14px;
padding: 6px 14px;
border-radius: 4px;
border-radius: 4px;
- font-size: 14px;
+ font-size: calc(14px * var(--ov-text-scale, 1));
display: none;
display: none;
}
}
@@ -143,7 +164,7 @@ body.ovg-frame-hidden #ov-footer {
@@ -143,7 +164,7 @@ body.ovg-frame-hidden #ov-footer {
display: flex;
display: flex;
align-items: center;
align-items: center;
justify-content: center;
justify-content: center;
- font-size: 11px;
+ font-size: calc(11px * var(--ov-text-scale, 1));
text-align: center;
text-align: center;
pointer-events: auto;
pointer-events: auto;
cursor: pointer;
cursor: pointer;
@@ -151,7 +172,7 @@ body.ovg-frame-hidden #ov-footer {
@@ -151,7 +172,7 @@ body.ovg-frame-hidden #ov-footer {
}
}
.slot.active {
.slot.active {
- border-color: #f0b040;
+ border-color: var(--g-accent-text);
}
}
.slot.holstered {
.slot.holstered {
@@ -167,7 +188,7 @@ body.ovg-frame-hidden #ov-footer {
@@ -167,7 +188,7 @@ body.ovg-frame-hidden #ov-footer {
position: absolute;
position: absolute;
right: 4px;
right: 4px;
bottom: 2px;
bottom: 2px;
- font-size: 12px;
+ font-size: calc(12px * var(--ov-text-scale, 1));
font-weight: 700;
font-weight: 700;
color: #f0d090;
color: #f0d090;
}
}
@@ -176,7 +197,7 @@ body.ovg-frame-hidden #ov-footer {
@@ -176,7 +197,7 @@ body.ovg-frame-hidden #ov-footer {
position: absolute;
position: absolute;
left: 4px;
left: 4px;
top: 2px;
top: 2px;
- font-size: 10px;
+ font-size: calc(10px * var(--ov-text-scale, 1));
color: #8fa0b0;
color: #8fa0b0;
}
}
@@ -191,9 +212,9 @@ body.ovg-frame-hidden #ov-footer {
@@ -191,9 +212,9 @@ body.ovg-frame-hidden #ov-footer {
}
}
.panel h2 {
.panel h2 {
- font-size: 15px;
+ font-size: calc(15px * var(--ov-text-scale, 1));
margin-bottom: 10px;
margin-bottom: 10px;
- color: #f0b040;
+ color: var(--g-accent-text);
letter-spacing: 0.06em;
letter-spacing: 0.06em;
text-transform: uppercase;
text-transform: uppercase;
}
}
@@ -235,11 +256,11 @@ body.ovg-frame-hidden #ov-footer {
@@ -235,11 +256,11 @@ body.ovg-frame-hidden #ov-footer {
.recipe .name {
.recipe .name {
font-weight: 600;
font-weight: 600;
- font-size: 14px;
+ font-size: calc(14px * var(--ov-text-scale, 1));
}
}
.recipe .req {
.recipe .req {
- font-size: 12px;
+ font-size: calc(12px * var(--ov-text-scale, 1));
color: #9fb0c0;
color: #9fb0c0;
margin: 4px 0;
margin: 4px 0;
}
}
@@ -249,13 +270,13 @@ body.ovg-frame-hidden #ov-footer {
@@ -249,13 +270,13 @@ body.ovg-frame-hidden #ov-footer {
}
}
.recipe button {
.recipe button {
- background: #2a5a8a;
- color: #fff;
+ background: var(--g-action);
+ color: var(--g-on-action);
border: 0;
border: 0;
border-radius: 4px;
border-radius: 4px;
padding: 5px 12px;
padding: 5px 12px;
cursor: pointer;
cursor: pointer;
- font-size: 13px;
+ font-size: calc(13px * var(--ov-text-scale, 1));
}
}
.recipe button:disabled {
.recipe button:disabled {
@@ -277,10 +298,10 @@ body.ovg-frame-hidden #ov-footer {
@@ -277,10 +298,10 @@ body.ovg-frame-hidden #ov-footer {
.toast {
.toast {
background: rgba(0, 0, 0, 0.7);
background: rgba(0, 0, 0, 0.7);
- border-left: 3px solid #f0b040;
+ border-left: 3px solid var(--g-accent);
padding: 6px 14px;
padding: 6px 14px;
border-radius: 4px;
border-radius: 4px;
- font-size: 13px;
+ font-size: calc(13px * var(--ov-text-scale, 1));
animation: fadeout 3.5s forwards;
animation: fadeout 3.5s forwards;
}
}
@@ -304,7 +325,7 @@ body.ovg-frame-hidden #ov-footer {
@@ -304,7 +325,7 @@ body.ovg-frame-hidden #ov-footer {
position: absolute;
position: absolute;
top: 10px;
top: 10px;
left: 12px;
left: 12px;
- font-size: 12px;
+ font-size: calc(12px * var(--ov-text-scale, 1));
color: #8fa0b0;
color: #8fa0b0;
}
}
/* In the world the folded Frame's OpenVibe pill sits in that corner: the status line goes under it. */
/* In the world the folded Frame's OpenVibe pill sits in that corner: the status line goes under it. */
@@ -325,9 +346,9 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -325,9 +346,9 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
}
}
.overlay-center h1 {
.overlay-center h1 {
- font-size: 42px;
+ font-size: calc(42px * var(--ov-text-scale, 1));
letter-spacing: 0.12em;
letter-spacing: 0.12em;
- color: #f0b040;
+ color: var(--g-accent-text);
}
}
.overlay-center input {
.overlay-center input {
@@ -336,15 +357,15 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -336,15 +357,15 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
border: 1px solid #3a4450;
border: 1px solid #3a4450;
border-radius: 6px;
border-radius: 6px;
padding: 10px 16px;
padding: 10px 16px;
- font-size: 16px;
+ font-size: calc(16px * var(--ov-text-scale, 1));
text-align: center;
text-align: center;
}
}
.overlay-center button {
.overlay-center button {
- background: #2a5a8a;
+ background: var(--g-action);
border: 0;
border: 0;
- color: #fff;
- font-size: 16px;
+ color: var(--g-on-action);
+ font-size: calc(16px * var(--ov-text-scale, 1));
padding: 10px 30px;
padding: 10px 30px;
border-radius: 6px;
border-radius: 6px;
cursor: pointer;
cursor: pointer;
@@ -352,7 +373,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -352,7 +373,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
.overlay-center .hint {
.overlay-center .hint {
color: #8fa0b0;
color: #8fa0b0;
- font-size: 13px;
+ font-size: calc(13px * var(--ov-text-scale, 1));
}
}
#skills-panel {
#skills-panel {
@@ -369,12 +390,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -369,12 +390,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
.skill-head {
.skill-head {
display: flex;
display: flex;
justify-content: space-between;
justify-content: space-between;
- font-size: 14px;
+ font-size: calc(14px * var(--ov-text-scale, 1));
margin-bottom: 4px;
margin-bottom: 4px;
}
}
.skill-level {
.skill-level {
- color: #f0b040;
+ color: var(--g-accent-text);
font-weight: 700;
font-weight: 700;
}
}
@@ -387,11 +408,11 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -387,11 +408,11 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
.skill-fill {
.skill-fill {
height: 100%;
height: 100%;
- background: linear-gradient(90deg, #2a5a8a, #58a4c4);
+ background: linear-gradient(90deg, var(--g-action), var(--g-action-edge));
}
}
.skill-xp {
.skill-xp {
- font-size: 11px;
+ font-size: calc(11px * var(--ov-text-scale, 1));
color: #8fa0b0;
color: #8fa0b0;
margin-top: 2px;
margin-top: 2px;
}
}
@@ -404,7 +425,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -404,7 +425,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
}
}
.hint-line {
.hint-line {
- font-size: 12px;
+ font-size: calc(12px * var(--ov-text-scale, 1));
color: #8fa0b0;
color: #8fa0b0;
margin-bottom: 10px;
margin-bottom: 10px;
}
}
@@ -415,17 +436,17 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -415,17 +436,17 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
align-items: center;
align-items: center;
padding: 6px 0;
padding: 6px 0;
border-bottom: 1px solid #232b33;
border-bottom: 1px solid #232b33;
- font-size: 14px;
+ font-size: calc(14px * var(--ov-text-scale, 1));
}
}
.trust-btn {
.trust-btn {
- background: #2a5a8a;
- color: #fff;
+ background: var(--g-action);
+ color: var(--g-on-action);
border: 0;
border: 0;
border-radius: 4px;
border-radius: 4px;
padding: 4px 12px;
padding: 4px 12px;
cursor: pointer;
cursor: pointer;
- font-size: 12px;
+ font-size: calc(12px * var(--ov-text-scale, 1));
}
}
.trust-btn.trusted {
.trust-btn.trusted {
@@ -453,9 +474,9 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -453,9 +474,9 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
}
}
.customize-panel h1 {
.customize-panel h1 {
- font-size: 26px;
+ font-size: calc(26px * var(--ov-text-scale, 1));
letter-spacing: 0.12em;
letter-spacing: 0.12em;
- color: #f0b040;
+ color: var(--g-accent-text);
margin-bottom: 12px;
margin-bottom: 12px;
}
}
@@ -467,12 +488,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -467,12 +488,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
border: 1px solid #3a4450;
border: 1px solid #3a4450;
border-radius: 6px;
border-radius: 6px;
padding: 8px 12px;
padding: 8px 12px;
- font-size: 15px;
+ font-size: calc(15px * var(--ov-text-scale, 1));
margin-bottom: 10px;
margin-bottom: 10px;
}
}
.cust-label {
.cust-label {
- font-size: 11px;
+ font-size: calc(11px * var(--ov-text-scale, 1));
text-transform: uppercase;
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.08em;
color: #8fa0b0;
color: #8fa0b0;
@@ -495,7 +516,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -495,7 +516,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
}
}
.swatch.selected {
.swatch.selected {
- border-color: #f0b040;
+ border-color: var(--g-accent-text);
}
}
.cust-btn {
.cust-btn {
@@ -505,12 +526,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -505,12 +526,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
border-radius: 5px;
border-radius: 5px;
padding: 5px 12px;
padding: 5px 12px;
cursor: pointer;
cursor: pointer;
- font-size: 13px;
+ font-size: calc(13px * var(--ov-text-scale, 1));
}
}
.cust-btn.selected {
.cust-btn.selected {
- background: #2a5a8a;
- border-color: #58a4c4;
+ background: var(--g-action);
+ border-color: var(--g-action-edge);
}
}
.cust-sliders {
.cust-sliders {
@@ -521,7 +542,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -521,7 +542,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
}
}
.cust-sliders label {
.cust-sliders label {
- font-size: 12px;
+ font-size: calc(12px * var(--ov-text-scale, 1));
color: #9fb0c0;
color: #9fb0c0;
display: flex;
display: flex;
justify-content: space-between;
justify-content: space-between;
@@ -547,12 +568,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -547,12 +568,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
border-radius: 6px;
border-radius: 6px;
padding: 10px 0;
padding: 10px 0;
cursor: pointer;
cursor: pointer;
- font-size: 14px;
+ font-size: calc(14px * var(--ov-text-scale, 1));
}
}
.cust-actions button.primary {
.cust-actions button.primary {
- background: #2a5a8a;
- border-color: #58a4c4;
+ background: var(--g-action);
+ border-color: var(--g-action-edge);
font-weight: 600;
font-weight: 600;
}
}
@@ -590,7 +611,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -590,7 +611,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
color: #8fa0b0;
color: #8fa0b0;
padding: 11px 10px;
padding: 11px 10px;
white-space: nowrap;
white-space: nowrap;
- font-size: 13px;
+ font-size: calc(13px * var(--ov-text-scale, 1));
letter-spacing: 0.05em;
letter-spacing: 0.05em;
cursor: pointer;
cursor: pointer;
border-bottom: 2px solid transparent;
border-bottom: 2px solid transparent;
@@ -604,8 +625,8 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -604,8 +625,8 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
}
}
.menu-tab.active {
.menu-tab.active {
- color: #f0b040;
- border-bottom-color: #f0b040;
+ color: var(--g-accent-text);
+ border-bottom-color: var(--g-accent-text);
}
}
.menu-body {
.menu-body {
@@ -675,13 +696,13 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -675,13 +696,13 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
.craft-btn {
.craft-btn {
flex: none;
flex: none;
- background: #2a5a8a;
- color: #fff;
+ background: var(--g-action);
+ color: var(--g-on-action);
border: 0;
border: 0;
border-radius: 6px;
border-radius: 6px;
padding: 8px 14px;
padding: 8px 14px;
cursor: pointer;
cursor: pointer;
- font-size: 13px;
+ font-size: calc(13px * var(--ov-text-scale, 1));
}
}
.craft-btn:disabled {
.craft-btn:disabled {
@@ -720,7 +741,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -720,7 +741,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
border: 0;
border: 0;
border-radius: 6px;
border-radius: 6px;
color: #8fa0b0;
color: #8fa0b0;
- font-size: 13px;
+ font-size: calc(13px * var(--ov-text-scale, 1));
letter-spacing: 0.03em;
letter-spacing: 0.03em;
cursor: pointer;
cursor: pointer;
transition:
transition:
@@ -734,7 +755,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -734,7 +755,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
.segmented-option.active {
.segmented-option.active {
background: #2a333d;
background: #2a333d;
- color: #f0b040;
+ color: var(--g-accent-text);
}
}
/* The first-steps waypoint: an arrow and a distance at the top centre, toward the current step's target. */
/* The first-steps waypoint: an arrow and a distance at the top centre, toward the current step's target. */
@@ -748,10 +769,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -748,10 +769,12 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
gap: 8px;
gap: 8px;
padding: 5px 12px 5px 8px;
padding: 5px 12px 5px 8px;
border-radius: 999px;
border-radius: 999px;
- border: 1px solid rgba(240, 176, 64, 0.45);
+ border: 1px solid var(--g-accent-soft);
background: rgba(14, 18, 24, 0.78);
background: rgba(14, 18, 24, 0.78);
color: #f3e6c8;
color: #f3e6c8;
- font: 600 13px/1.2 system-ui, sans-serif;
+ font:
+ 600 calc(13px * var(--ov-text-scale, 1))/1.2 system-ui,
+ sans-serif;
pointer-events: none;
pointer-events: none;
white-space: nowrap;
white-space: nowrap;
}
}
@@ -765,9 +788,9 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -765,9 +788,9 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
width: 22px;
width: 22px;
height: 22px;
height: 22px;
border-radius: 50%;
border-radius: 50%;
- background: #f0b040;
- color: #1a1206;
- font-size: 15px;
+ background: var(--g-accent);
+ color: var(--g-on-accent);
+ font-size: calc(15px * var(--ov-text-scale, 1));
font-weight: 800;
font-weight: 800;
transition: transform 0.15s linear;
transition: transform 0.15s linear;
}
}
@@ -778,17 +801,17 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -778,17 +801,17 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
width: 100%;
width: 100%;
min-height: 40px;
min-height: 40px;
margin: 2px 0 6px;
margin: 2px 0 6px;
- accent-color: #f0b040;
+ accent-color: var(--g-accent-text);
}
}
.segmented-option:focus-visible {
.segmented-option:focus-visible {
- outline: 2px solid #f0b040;
+ outline: 2px solid var(--g-accent);
outline-offset: 1px;
outline-offset: 1px;
}
}
.weapon-title {
.weapon-title {
- font-size: 15px;
- color: #f0b040;
+ font-size: calc(15px * var(--ov-text-scale, 1));
+ color: var(--g-accent-text);
letter-spacing: 0.06em;
letter-spacing: 0.06em;
text-transform: uppercase;
text-transform: uppercase;
margin-bottom: 10px;
margin-bottom: 10px;
@@ -799,7 +822,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -799,7 +822,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
}
}
.weapon-opt-label {
.weapon-opt-label {
- font-size: 12px;
+ font-size: calc(12px * var(--ov-text-scale, 1));
color: #9fb0c0;
color: #9fb0c0;
margin-bottom: 5px;
margin-bottom: 5px;
}
}
@@ -833,7 +856,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -833,7 +856,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
min-height: 0;
min-height: 0;
}
}
.status-chip {
.status-chip {
- font-size: 10px;
+ font-size: calc(10px * var(--ov-text-scale, 1));
letter-spacing: 0.04em;
letter-spacing: 0.04em;
color: #e8edf2;
color: #e8edf2;
background: rgba(10, 14, 18, 0.6);
background: rgba(10, 14, 18, 0.6);
@@ -844,7 +867,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -844,7 +867,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
}
}
.vital span {
.vital span {
width: 38px;
width: 38px;
- font-size: 10px;
+ font-size: calc(10px * var(--ov-text-scale, 1));
letter-spacing: 0.08em;
letter-spacing: 0.08em;
color: #cdd3da;
color: #cdd3da;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
@@ -983,7 +1006,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -983,7 +1006,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
font-weight: 600;
font-weight: 600;
}
}
.shop-cost {
.shop-cost {
- font-size: 12px;
+ font-size: calc(12px * var(--ov-text-scale, 1));
color: #9ab08e;
color: #9ab08e;
}
}
.shop-cost.missing {
.shop-cost.missing {
@@ -999,7 +1022,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -999,7 +1022,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
color: #f0d79a;
color: #f0d79a;
border-radius: 10px;
border-radius: 10px;
padding: 10px 22px;
padding: 10px 22px;
- font-size: 16px;
+ font-size: calc(16px * var(--ov-text-scale, 1));
letter-spacing: 0.03em;
letter-spacing: 0.03em;
opacity: 0;
opacity: 0;
transition: opacity 0.4s ease;
transition: opacity 0.4s ease;
@@ -1020,7 +1043,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -1020,7 +1043,7 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
background: rgba(20, 26, 34, 0.86);
background: rgba(20, 26, 34, 0.86);
border: 1px solid rgba(232, 181, 74, 0.55);
border: 1px solid rgba(232, 181, 74, 0.55);
color: #e8e2d4;
color: #e8e2d4;
- font-size: 13px;
+ font-size: calc(13px * var(--ov-text-scale, 1));
line-height: 1.35;
line-height: 1.35;
pointer-events: auto;
pointer-events: auto;
}
}
@@ -1035,14 +1058,14 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -1035,14 +1058,14 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
font-weight: 700;
font-weight: 700;
letter-spacing: 0.04em;
letter-spacing: 0.04em;
text-transform: uppercase;
text-transform: uppercase;
- font-size: 11px;
+ font-size: calc(11px * var(--ov-text-scale, 1));
color: #e8b54a;
color: #e8b54a;
}
}
.first-steps-head button {
.first-steps-head button {
background: none;
background: none;
border: 0;
border: 0;
color: #b9b2a3;
color: #b9b2a3;
- font-size: 18px;
+ font-size: calc(18px * var(--ov-text-scale, 1));
line-height: 1;
line-height: 1;
cursor: pointer;
cursor: pointer;
padding: 0 2px;
padding: 0 2px;
@@ -1081,13 +1104,13 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -1081,13 +1104,13 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
margin: 2px 0 4px 1.2em;
margin: 2px 0 4px 1.2em;
font-weight: 400;
font-weight: 400;
color: #cfc8b8;
color: #cfc8b8;
- font-size: 12px;
+ font-size: calc(12px * var(--ov-text-scale, 1));
}
}
@media (max-width: 640px) {
@media (max-width: 640px) {
.first-steps {
.first-steps {
top: auto;
top: auto;
bottom: 96px;
bottom: 96px;
- font-size: 12px;
+ font-size: calc(12px * var(--ov-text-scale, 1));
}
}
}
}
@@ -1117,14 +1140,14 @@ body:has(.ov-touch:not([hidden])) .status {
@@ -1117,14 +1140,14 @@ body:has(.ov-touch:not([hidden])) .status {
}
}
body:has(.ov-touch:not([hidden])) .waypoint {
body:has(.ov-touch:not([hidden])) .waypoint {
top: 52px;
top: 52px;
- font-size: 12px;
+ font-size: calc(12px * var(--ov-text-scale, 1));
}
}
body:has(.ov-touch:not([hidden])) .first-steps {
body:has(.ov-touch:not([hidden])) .first-steps {
top: 92px;
top: 92px;
bottom: auto;
bottom: auto;
right: 8px;
right: 8px;
width: min(250px, calc(100vw - 16px));
width: min(250px, calc(100vw - 16px));
- font-size: 12px;
+ font-size: calc(12px * var(--ov-text-scale, 1));
}
}
/* Only the step in progress: the list would cover a phone's view. */
/* Only the step in progress: the list would cover a phone's view. */
body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
@@ -1149,7 +1172,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
@@ -1149,7 +1172,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
opacity: 1;
opacity: 1;
}
}
.death-title {
.death-title {
- font-size: 64px;
+ font-size: calc(64px * var(--ov-text-scale, 1));
font-weight: 800;
font-weight: 800;
letter-spacing: 0.35em;
letter-spacing: 0.35em;
color: #c33;
color: #c33;
@@ -1167,11 +1190,11 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
@@ -1167,11 +1190,11 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
width: 100%;
width: 100%;
padding: 14px 16px;
padding: 14px 16px;
margin-top: 8px;
margin-top: 8px;
- font-size: 15px;
+ font-size: calc(15px * var(--ov-text-scale, 1));
}
}
.char-slot span {
.char-slot span {
color: #78828e;
color: #78828e;
- font-size: 12px;
+ font-size: calc(12px * var(--ov-text-scale, 1));
}
}
.char-locked {
.char-locked {
@@ -1184,7 +1207,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
@@ -1184,7 +1207,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
background: rgba(40, 60, 86, 0.55);
background: rgba(40, 60, 86, 0.55);
border: 1px solid #34506e;
border: 1px solid #34506e;
border-radius: 10px;
border-radius: 10px;
- font-size: 13px;
+ font-size: calc(13px * var(--ov-text-scale, 1));
color: #b8c6d6;
color: #b8c6d6;
line-height: 1.5;
line-height: 1.5;
}
}
@@ -1197,7 +1220,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
@@ -1197,7 +1220,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
width: 100%;
width: 100%;
margin-top: 10px;
margin-top: 10px;
padding: 13px 16px;
padding: 13px 16px;
- font-size: 15px;
+ font-size: calc(15px * var(--ov-text-scale, 1));
font-weight: 700;
font-weight: 700;
background: linear-gradient(180deg, #3b6a96, #2a4d70);
background: linear-gradient(180deg, #3b6a96, #2a4d70);
border-color: #4b7cab;
border-color: #4b7cab;
@@ -1212,7 +1235,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
@@ -1212,7 +1235,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
background: rgba(60, 50, 25, 0.5);
background: rgba(60, 50, 25, 0.5);
border: 1px solid #6e5a2e;
border: 1px solid #6e5a2e;
border-radius: 8px;
border-radius: 8px;
- font-size: 12.5px;
+ font-size: calc(12.5px * var(--ov-text-scale, 1));
color: #d8c9a0;
color: #d8c9a0;
line-height: 1.45;
line-height: 1.45;
}
}
@@ -1231,7 +1254,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
@@ -1231,7 +1254,7 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
border: 1px solid #ffce54;
border: 1px solid #ffce54;
border-radius: 8px;
border-radius: 8px;
color: #fff3d6;
color: #fff3d6;
- font-size: 13px;
+ font-size: calc(13px * var(--ov-text-scale, 1));
font-weight: 700;
font-weight: 700;
letter-spacing: 0.04em;
letter-spacing: 0.04em;
pointer-events: none;
pointer-events: none;
@@ -1257,12 +1280,12 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
@@ -1257,12 +1280,12 @@ body:has(.ov-touch:not([hidden])) .first-steps li:not(.current) {
background: rgba(30, 70, 45, 0.45);
background: rgba(30, 70, 45, 0.45);
border: 1px solid #3f7a55;
border: 1px solid #3f7a55;
border-radius: 8px;
border-radius: 8px;
- font-size: 13px;
+ font-size: calc(13px * var(--ov-text-scale, 1));
color: #a8dcb8;
color: #a8dcb8;
}
}
.char-logout {
.char-logout {
padding: 5px 12px;
padding: 5px 12px;
- font-size: 12px;
+ font-size: calc(12px * var(--ov-text-scale, 1));
background: rgba(90, 40, 40, 0.6);
background: rgba(90, 40, 40, 0.6);
border: 1px solid #8a4a4a;
border: 1px solid #8a4a4a;
border-radius: 6px;
border-radius: 6px;
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it } from 'vitest'
+import { _resetTheme, calm, motionScale, onCalmChange, themeColor } from './theme.js'
+
+const flush = () => new Promise((r) => setTimeout(r, 0))
+
+describe("the player's OpenVibe display preferences in the engine", () => {
+ afterEach(() => {
+ document.documentElement.removeAttribute('data-ov-motion')
+ document.documentElement.style.removeProperty('--accent')
+ _resetTheme()
+ })
+
+ it('keeps all the motion unless calm motion is on, and follows it as it changes', async () => {
+ expect(calm()).toBe(false)
+ expect(motionScale()).toBe(1)
+ const seen: boolean[] = []
+ onCalmChange((c) => seen.push(c))
+ document.documentElement.setAttribute('data-ov-motion', 'reduced') // the theme loader, after a change on any site
+ await flush()
+ expect(calm()).toBe(true)
+ expect(motionScale()).toBe(0.2)
+ window.dispatchEvent(new Event('ov:display'))
+ document.documentElement.removeAttribute('data-ov-motion')
+ await flush()
+ expect(motionScale()).toBe(1)
+ expect(seen).toEqual([true, false])
+ })
+
+ it("reads the theme's colours for canvas-drawn UI, with a fallback", () => {
+ expect(themeColor('--accent', '#f0b040')).toBe('#f0b040')
+ document.documentElement.style.setProperty('--accent', '#ff7a1a')
+ expect(themeColor('--accent', '#f0b040')).toBe('#ff7a1a')
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+/**
+ * The player's OpenVibe display preferences in the engine.
+ *
+ * Every page runs the shared theme loader (theme-loader.js), which paints the theme someone picked on any OpenVibe site
+ * onto <html> (the HUD's CSS reads those tokens: styles.css :root --g-*) and keeps their display preferences: text size
+ * (--ov-text-scale, also read by the CSS) and calm motion (html[data-ov-motion=reduced]). This module is what CSS cannot
+ * reach: how much of the engine's own decorative motion to keep (the weapon's bob and sway, the hit wobble), current as
+ * the person changes it in another tab or on another site, and the theme's colours for anything drawn on a canvas.
+ */
+
+let calmNow = false
+let started = false
+const listeners = new Set<(calm: boolean) => void>()
+
+function read(): boolean {
+ if (typeof document === 'undefined') return false
+ if (document.documentElement.getAttribute('data-ov-motion') === 'reduced') return true
+ try {
+ return (
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
+ )
+ } catch {
+ return false
+ }
+}
+
+function refresh(): void {
+ const c = read()
+ if (c === calmNow) return
+ calmNow = c
+ listeners.forEach((fn) => fn(c))
+}
+
+function start(): void {
+ if (started) return
+ started = true
+ calmNow = read()
+ if (typeof window === 'undefined') return
+ window.addEventListener('ov:display', refresh)
+ try {
+ matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', refresh)
+ } catch {
+ /* no media queries */
+ }
+ try {
+ new MutationObserver(refresh).observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ['data-ov-motion'],
+ })
+ } catch {
+ /* no DOM */
+ }
+}
+
+/** Calm motion is on (the OpenVibe setting or the system's reduced-motion setting). */
+export function calm(): boolean {
+ start()
+ return calmNow
+}
+
+/** How much of the engine's decorative motion to keep: 1, or 0.2 when calm. */
+export function motionScale(): number {
+ return calm() ? 0.2 : 1
+}
+
+/** Called with the new value whenever calm motion turns on or off; returns a function that stops it. */
+export function onCalmChange(fn: (calm: boolean) => void): () => void {
+ start()
+ listeners.add(fn)
+ return () => listeners.delete(fn)
+}
+
+/** One of the theme's colours (a CSS custom property on <html>, like '--accent'), or the fallback. */
+export function themeColor(name: string, fallback: string): string {
+ if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') return fallback
+ const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
+ return v || fallback
+}
+
+/** Tests only: forget what was read. */
+export function _resetTheme(): void {
+ started = false
+ calmNow = false
+ listeners.clear()
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/client/src/ui/theme.test.ts'
+// covers: 'apps/client/src/ui'
+// Generated by node test/run.js --index; do not edit.