diff --git a/README.md b/README.md index d1aa060..f0ab73d 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,22 @@ npm install npm run dev ``` +## Play controls + +The bottom mode dock is the local-player source of truth: View, Drive, Explore, +Fly, or office Walk. A transition clears stale held input and atomically hands +the follow camera to one subsystem. `WASD` is movement; `Q/E` is vertical or +yaw, `I/K` pitches the crow, Space is the primary action, `G` glides, `P` +resumes assistance, `R` resets, and `C` switches the driving camera. A standard +gamepad maps both sticks, triggers, shoulders, and rising-edge action buttons. + +Touch play uses a pointer-ID analogue stick at lower left and only the actions +that apply to the current mode at lower right. The Map button remains available +during possession. Touch, keyboard, and gamepad state are independent, so a +released or cancelled finger cannot clear another source that is still held. +The UI and follow camera are presentation adapters only; they never enter Arena +observations, rewards, snapshots, traces, or simulator hashes. + ## Headless RL environments Tera also exports a versioned, renderer-independent Arena contract with five diff --git a/index.html b/index.html index 6464f5d..94c2897 100644 --- a/index.html +++ b/index.html @@ -765,6 +765,72 @@ than mystery glyphs: the controls disappear everywhere space is tight except on the devices that cannot drive without them. */ .drive-controls, .walk-controls { display: none; } + .play-hud { + position: fixed; + top: calc(var(--s4) + env(safe-area-inset-top)); + left: 50%; + z-index: 4; + min-width: min(27rem, calc(100vw - 12rem)); + max-width: calc(100vw - var(--s4) * 2); + transform: translateX(-50%); + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: baseline; + gap: var(--s2) var(--s3); + padding: var(--s2) var(--s3); + pointer-events: none; + color: var(--ink-2); + background: rgba(8, 12, 17, 0.78); + border: 1px solid var(--hairline); + border-radius: var(--r); + box-shadow: var(--shadow); + backdrop-filter: var(--blur); + -webkit-backdrop-filter: var(--blur); + font-size: 10px; + font-variant-numeric: tabular-nums; + letter-spacing: 0.05em; + } + .play-hud[hidden] { display: none; } + .play-hud-mode { color: var(--amber-ink); text-transform: uppercase; letter-spacing: 0.11em; } + .play-hud-primary { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .play-hud-status { text-align: right; color: var(--ink-3); } + .play-hud-status.warning { color: #ff9d72; } + .mode-dock { + position: fixed; + left: 50%; + bottom: calc(var(--s4) + env(safe-area-inset-bottom)); + z-index: 4; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 3px; + padding: 4px; + border: 1px solid var(--hairline); + border-radius: 999px; + background: rgba(8, 12, 17, 0.82); + box-shadow: var(--shadow); + backdrop-filter: var(--blur); + -webkit-backdrop-filter: var(--blur); + } + .mode-dock button { + min-width: 44px; + min-height: 38px; + padding: 5px 10px; + border: 0; + border-radius: 999px; + color: var(--ink-3); + background: transparent; + font: inherit; + font-size: 9px; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + } + .mode-dock button[aria-pressed="true"] { + color: var(--amber-ink); + background: rgba(242, 177, 52, 0.2); + } + .mode-dock button[hidden] { display: none; } @media (pointer: coarse) { .drive-controls:not([hidden]), .walk-controls:not([hidden]) { position: fixed; @@ -807,6 +873,142 @@ body:has(.drive-controls:not([hidden])) .source, body:has(.walk-controls:not([hidden])) .source { bottom: 7.5rem; } } + body.touch-capable .drive-controls:not([hidden]), + body.touch-capable .walk-controls:not([hidden]) { + position: fixed; + left: 50%; + bottom: calc(var(--s3) + env(safe-area-inset-bottom)); + transform: translateX(-50%); + z-index: 5; + width: min(23rem, calc(100vw - var(--s4) * 2)); + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--s1); + padding: var(--s1); + border: 1px solid var(--hairline); + border-radius: var(--r); + background: var(--glass-strong); + backdrop-filter: var(--blur); + -webkit-backdrop-filter: var(--blur); + box-shadow: var(--shadow); + } + body.touch-capable .drive-control, + body.touch-capable .walk-control { + min-height: 48px; + border: 1px solid var(--hairline); + border-radius: var(--r-sm); + background: rgba(255, 255, 255, 0.08); + color: var(--ink); + font: inherit; + font-size: 10px; + letter-spacing: 0.05em; + text-transform: uppercase; + touch-action: none; + user-select: none; + -webkit-user-select: none; + } + body.touch-capable .drive-control[aria-pressed="true"], + body.touch-capable .walk-control[aria-pressed="true"] { + border-color: rgba(242, 177, 52, 0.65); + background: rgba(242, 177, 52, 0.28); + color: var(--amber-ink); + } + body:has(.drive-controls:not([hidden])) .mode-dock, + body:has(.drive-controls:not([hidden])) .rail { bottom: calc(8rem + env(safe-area-inset-bottom)); } + body:has(.walk-controls:not([hidden])) .mode-dock, + body:has(.walk-controls:not([hidden])) .rail { bottom: calc(11.5rem + env(safe-area-inset-bottom)); } + + /* GTA-like touch surface: analogue movement on the left, only the + actions meaningful to the current vehicle/actor on the right. */ + .touch-play-controls { display: none; } + @media (pointer: coarse) { + .touch-play-controls:not([hidden]) { display: block; } + } + body.touch-capable .touch-play-controls:not([hidden]) { display: block; } + .touch-play-controls:not([hidden]) { + position: fixed; + inset: 0; + z-index: 5; + pointer-events: none; + } + .play-stick { + --stick-x: 0px; + --stick-y: 0px; + position: absolute; + left: max(var(--s3), env(safe-area-inset-left)); + bottom: calc(var(--s3) + env(safe-area-inset-bottom)); + width: clamp(7.5rem, 31vw, 9rem); + aspect-ratio: 1; + border-radius: 50%; + pointer-events: auto; + touch-action: none; + user-select: none; + -webkit-user-select: none; + background: radial-gradient(circle, rgba(255,255,255,.08) 0 38%, rgba(8,12,17,.78) 40% 100%); + border: 1px solid rgba(255,255,255,.18); + box-shadow: inset 0 0 24px rgba(0,0,0,.35), var(--shadow); + backdrop-filter: var(--blur); + -webkit-backdrop-filter: var(--blur); + } + .play-stick-ring { + position: absolute; + inset: 21%; + border: 1px solid rgba(242,177,52,.32); + border-radius: 50%; + } + .play-stick-knob { + position: absolute; + left: 50%; + top: 50%; + width: 3.25rem; + height: 3.25rem; + transform: translate(calc(-50% + var(--stick-x)), calc(-50% + var(--stick-y))); + border-radius: 50%; + background: rgba(242,177,52,.28); + border: 1px solid rgba(242,177,52,.72); + box-shadow: 0 4px 14px rgba(0,0,0,.45); + transition: transform 80ms ease-out; + } + .play-stick.active .play-stick-knob { transition: none; } + .touch-actions { + position: absolute; + right: max(var(--s3), env(safe-area-inset-right)); + bottom: calc(var(--s3) + env(safe-area-inset-bottom)); + width: min(10rem, 42vw); + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px; + pointer-events: auto; + } + .touch-actions button { + min-height: 46px; + padding: 5px 7px; + border: 1px solid rgba(255,255,255,.18); + border-radius: 999px; + background: rgba(8,12,17,.78); + color: var(--ink-2); + box-shadow: var(--shadow); + backdrop-filter: var(--blur); + -webkit-backdrop-filter: var(--blur); + font: inherit; + font-size: 9px; + letter-spacing: .06em; + text-transform: uppercase; + touch-action: none; + user-select: none; + -webkit-user-select: none; + } + .touch-actions button[hidden] { display: none; } + .touch-actions button[aria-pressed="true"] { + border-color: rgba(242,177,52,.72); + background: rgba(242,177,52,.28); + color: var(--amber-ink); + } + body:has(.touch-play-controls:not([hidden])) .mode-dock { + bottom: calc(10rem + env(safe-area-inset-bottom)); + } + body:has(.touch-play-controls:not([hidden])) #hint { display: none; } + body:has(.touch-play-controls:not([hidden])) .source { bottom: 10rem; } /* ---- Responsive ------------------------------------------------------- Two breakpoints and no more. At 900 the left column stops being furniture @@ -850,6 +1052,18 @@ `deviceProfile()` in `stage.ts` keys off this exact 600px edge — so the stylesheet and the pixel budget cannot drift apart. */ @media (max-width: 600px) { + .play-hud { + top: calc(var(--s3) + env(safe-area-inset-top) + 2.65rem); + min-width: 0; + width: calc(100vw - var(--s3) * 2); + } + .mode-dock { + max-width: calc(100vw - var(--s3) * 2); + bottom: calc(var(--s3) + env(safe-area-inset-bottom)); + } + .mode-dock button { padding-inline: 8px; } + body:has(.drive-controls:not([hidden])) #hint, + body:has(.walk-controls:not([hidden])) #hint { display: none; } /* Everything hit by a finger clears 44px, which is 11 steps of the 4px rhythm and the smallest target anyone has managed to defend. `#help` was 17px tall. */ @@ -1039,28 +1253,36 @@ - + -