diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ee0ef6..193aa82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,61 @@ Changes are listed newest first, with an independent version for every individua T5/T6 script and each native plugin. Current versions are recorded in [versions.json](versions.json); see the [version policy](README.md#versioning). +## 2026-10-14 + +### Fixed upper-right core HUD and shared service window + +| Component | Version | Affected source | +| --- | --- | --- | +| `tws_t5_common.gsc` | `1.2.0` | `t5/scripts/tws_t5_common.gsc` | +| `tws_t6_common.gsc` | `1.2.0` | `t6/scripts/tws_t6_common.gsc` | +| `tws_t5_hud.gsc` | `1.2.0` | `t5/scripts/tws_t5_hud.gsc` | +| `tws_t6_hud.gsc` | `1.2.0` | `t6/scripts/tws_t6_hud.gsc` | +| `tws_t5_leaderboard.gsc` | `1.1.0` | `t5/scripts/tws_t5_leaderboard.gsc` | +| `tws_t6_leaderboard.gsc` | `1.1.0` | `t6/scripts/tws_t6_leaderboard.gsc` | +| `tws_t5_afk.gsc` | `1.0.1` | `t5/scripts/tws_t5_afk.gsc` | +| `tws_t6_afk.gsc` | `1.0.1` | `t6/scripts/tws_t6_afk.gsc` | + +- Both engines use a fixed upper-right core panel below the top edge, with + `TWS ZOMBIES`, `discord.tws.lol`, side-by-side match/round timers, zombie count, + current/maximum HP even at full health, and a full-width health bar. Replace + the co-op name row with reserved footer space for the AFK indicator. Core + creation on connection supports spectator placeholders without seeding the + match clock early. Preserve each engine's timer behavior and frozen clocks at + game end; health and zombie-count workers now freeze on both engines as well. + Missing max-health values use a display fallback without modifying gameplay health. +- Common owns one fixed-height service window below the core. Help shows four + commands per page for 12 seconds each, in stable order and filtered by loaded + modules. `.help` / `.commands` restart at page one. The final page dismisses + automatically; opening a service never shifts or resizes the core. +- Render help with two fixed multiline text columns and a muted command-column + backdrop. The service uses 19 nonarchived HUD elements regardless of command + or page count; the core and AFK use 15 + 1 archived elements. Reuse text and + numeric displays instead of allocating elements for every command/page. +- Records use the same window, with a prominent round, page count, replay hint, + and up to three holder lines per page. Abbreviate overlong individual names + without splitting UTF-8 characters; retain every tied holder across bounded + lines and pages. Help pauses records and resumes their remaining page time; + `.leaders` during help replaces the pending record from page one. Unavailable + records are distinct from an empty recorded history. Help and records remain + private and work without the HUD module. Reused elements and request state + protect newer displays from stale timers and clean up on disconnect/game end. +- Compatibility: HUD `1.2.0`, leaderboard `1.1.0`, and AFK `1.0.1` require the + matching game's common `1.2.0+`. Existing common helper signatures remain + available for unchanged consumers. No new native-plugin, backend or external + dependency requirement; existing leaderboard HTTP/JSON requirements still apply. + Both T5 and T6 receive companion implementations adapted to their engines. +- Deployment: copy each game's matching common, HUD, leaderboard and AFK scripts + that are installed, then restart the map. No plugin update or full server-process + restart is required for this change. No deployment performed. +- Validation: `node tools/check_hud.mjs` passed 8 core and 35 service checks; + `node tools/check_leaderboard.mjs` passed 143 checks, `node tools/check_bank.mjs` + passed 42 fixture groups, `node tools/check_join.mjs` passed 17 checks, and + `node tools/check_telemetry.mjs` passed 23 checks. These use engine shims. + No local GSC compiler or PowerShell was available; GSC compilation and live + Windows/Plutonium validation remain required. Follow the + [HUD validation guide](README.md#hud-validation) before rollout. + ## 2026-10-13 ### Shorter Discord URL diff --git a/README.md b/README.md index 563f42f..207de54 100644 --- a/README.md +++ b/README.md @@ -359,16 +359,87 @@ For this upgrade also verify stale URL settings are ignored, an empty `.b` shows zero, `.w all` reports insufficient balance, and an EC rebuild/removal settles legacy and public journals without duplicate ledger entries or lost points. +## Core HUD and service window (T5 and T6) + +The HUD uses a fixed upper-right panel below the top edge, leaving room above +for performance overlays and below for stock player scores, ammo and equipment. +The header contains `TWS ZOMBIES` and `discord.tws.lol`; match and round timers +share the next row. Zombie count and current/maximum HP sit above a full-width +health bar. HP numbers remain visible at full health. A reserved footer holds +the AFK indicator; the former co-op player-name row is removed. + +The core is created once per connection and stays in place when services open, +across death and respawn, and while newcomers spectate. Unavailable values use +placeholders until the first-spawn callback binds the data displays. Creating the +panel does not start the match clock. Each engine retains its timer behavior; +clocks, health and zombie-count displays freeze at game over. Health display +fallbacks never modify gameplay max health. + +Common owns one fixed-height service window directly below the core. `.help` +and its `.commands` alias show four commands per page, with command and +description columns plus a page count. Pages advance automatically every +12 seconds and close after the last page. Repeating either command restarts +page one. The order is `.help`, `.afk`, `.back`, `.join`, `.leaders`, `.b`, `.d`, +`.w`, omitting commands whose modules are not registered. The `.join` description +continues to identify its newcomer restriction. + +Records use the same service window. Help pauses a visible record and resumes +its remaining page time when help ends. `.leaders` during help replaces the +pending record with a fresh replay from page one. One service is visible at a +time, and neither changes the core's position or height. Services are private +to the requesting player and work when the HUD module is omitted. + +Install HUD `1.2.0`, leaderboard `1.1.0` and AFK `1.0.1` with the matching +game's common `1.2.0+`; update the installed scripts together and restart the map. +There is no new native-plugin or backend requirement, and this change does not +require a full server-process restart. Existing leaderboard plugin requirements +still apply. Unchanged modules retain their existing common helper interfaces. + +### HUD validation + +Run `node tools/check_hud.mjs` for portable pagination, service arbitration, +request replacement and cleanup checks, and `node tools/check_leaderboard.mjs` +for record data handling. Run `node tools/check_bank.mjs`, +`node tools/check_join.mjs` and `node tools/check_telemetry.mjs` for unchanged +command/lifecycle regressions. These +checks use engine shims and do not prove GSC VM behavior. Follow the +[T6 compiler instructions](t6/scripts/README.md#validation); T5 requires a live +Plutonium server because gsc-tool has no T5 backend. + +Before rollout, validate both engines in Plutonium: + +1. Check 16:9 and narrower/wider resolutions, safe-area settings, long match + timers, stock map inventory/objectives, performance overlays, player scores, + ammo and interaction prompts. The core and service panels must stay clear of + these areas and retain their geometry for every page. +2. Join before spawning, join mid-match, die/respawn and reconnect. Verify one + core per player, spectator placeholders, no early match-clock start, correct + health/Juggernog changes, and frozen timers at game over. Toggle AFK and back + without overlapping the health bar or changing existing protection behavior. +3. Open `.help`, `.HELP` and `.commands` with all modules installed. Verify two + pages of four commands, 12 seconds per page and automatic dismissal. Repeat + on page two to return to page one. Remove optional modules and restart the + map to confirm filtered pages, then restore them. +4. Interrupt records with help and check that the interrupted page resumes for + its remaining time. Repeat `.leaders` while help is open; only the latest + replay should start, at page one. Check long holder names, ties spanning + pages, no records, and unavailable records. Repeat without the HUD installed. +5. Disconnect or end the game while help/records are active, reconnect and + request them again. Verify no duplicate elements, stale timeout closures or + surviving service windows. Confirm `.join`, bank commands and lobby lock + retain their existing behavior with their required modules installed. + ## Map record announcements Install `tws_t5_leaderboard.gsc` for T5 or `tws_t6_leaderboard.gsc` for T6, -**along with the updated common module** for that game. -The new module privately announces the current map's fleet-wide highest recorded +**along with common `1.2.0+`** for that game when using leaderboard `1.1.0`. +The module privately announces the current map's fleet-wide highest recorded round and every tied holder once per connection, including spectator newcomers. Type `.leaders` to replay the record privately from the latest usable cache. Repeating the command restarts the announcement from its first page. It appears in `.help` when the leaderboard module is enabled. If no usable record is -available, the command asks you to try again shortly. +available, the service window identifies the record as unavailable and keeps +the `.leaders` retry reminder; this differs from a valid empty record history. It reads Electric Cherry's public `/api/leaderboard` endpoint without a token; only completed matches contribute. Ties may come from the same run or separate runs. Names are the API's latest player names, with players identified by GUID. @@ -390,13 +461,17 @@ exist in the API's map list and match the current game and engine map. Clear it when changing variants; restart the map after changing these settings. The announcement starts five seconds after player readiness and waits for the -initial record fetch if necessary. It appears in the same HUD area as `.help`, -below the health bar, with a heading such as `Record: Round 23` and holder names -underneath. Each page shows up to three holder lines for 12 seconds. Opening -`.help` hides the record and pauses its display time until help closes. -All holders are included; colour/control codes are removed, and unusually long -names are shortened to keep messages readable. HUD elements are removed when -the announcement finishes, the player disconnects, or the game ends. +initial record fetch if necessary. It appears in the common-owned service +window beneath the core, with a `MAP RECORD` heading, prominent round value, +holder names, page count and `.leaders` replay hint. Each page shows up to three +holder lines for 12 seconds. Opening `.help` pauses the record's remaining +page time until help closes. A `.leaders` request during help replaces any +pending replay and starts from page one after help closes. +All holders are included; colour/control codes are removed, and overlong +individual names are abbreviated without splitting UTF-8 characters. Bounded +lines and additional pages preserve every tied holder. The window hides when the +announcement finishes and its elements are reused for subsequent services. +Disconnect or game end cleans up service elements. One level worker fetches the map list and pages of three players, stopping when the highest-round tie ends. Successful snapshots refresh every five minutes. diff --git a/t5/scripts/NOTES.md b/t5/scripts/NOTES.md index b99bf69..eebd334 100644 --- a/t5/scripts/NOTES.md +++ b/t5/scripts/NOTES.md @@ -32,9 +32,11 @@ before copying the renamed scripts. - **`SetTimerUp(N)` only ticks when `N == 0`** on T5 SP. Non-zero seeds render "0:00" but don't advance. Late joiners see 0, not real elapsed; authoritative elapsed lives in `level.match_start_time`. -- **Dynamic text: use `SetValue`, not `SetText`.** `SetText` allocates a - config string per call; per-tick rebuilds overflow and take the server - down. Composite strings like `"87/160"` are three elements. +- **Dynamic numbers use `SetValue`.** Repeatedly generating unique strings + risks exhausting the text config-string pool; identical strings can be reused + by the engine's [string lookup](https://github.com/MisaDev4/bo1-zombies-decompiled/blob/102f6992be332610aff587e681e3180b15f395bb/src/game_mp/g_utils_mp.cpp#L46). + Composite HP uses numeric elements around a static slash. Help updates two + multiline columns only on page changes, and record rows skip unchanged text. - **`NewClientHudElem(player)`, not `NewHudElem()`.** `NewHudElem` is globally owned; in co-op every player's HUD draws on top of every other player's. Fall back to `NewHudElem` only if elements don't render at all @@ -56,18 +58,40 @@ before copying the renamed scripts. Many BO1 maps pre-set `round_number = 1` at init, so the round watcher fires on transitions only and the timer row seeds on first spawn as a fallback. +- **Connection-time HUD creation must not seed clocks.** The upper-right core + is available to waiting spectators with placeholders. Timer updates retain + their existing spawn/round lifecycle, independently of element creation. +- **One service window per player.** Common owns fixed geometry, ordered + four-command help pages, record display state and cleanup. Help pauses record + page time; `.leaders` during help replaces the pending replay from page one. + Keep both services independent of the HUD module. The core never moves or + resizes when a service opens, and the AFK indicator uses its reserved footer. +- **Bound HUD element counts, including hidden elements.** Core + AFK use + 15 + 1 archived elements; the service uses 19 nonarchived elements regardless + of command/page count. Help reuses two multiline columns and one muted column + backdrop. Hiding an element does not free its slot. The + [BO1 HUD update](https://github.com/MisaDev4/bo1-zombies-decompiled/blob/102f6992be332610aff587e681e3180b15f395bb/src/game/g_hudelem.cpp#L2596) + shows 31 slots per bucket; this is not proof of current Plutonium stock HUD + usage, which still needs live validation. - **Game-over trigger is level-scope, raced across three candidate notifies** (`end_game` / `game_ended` / `intermission`) since BO1 maps are inconsistent. Per-player triggers do not work — `self waittill("death")` is intercepted by the zombiemode scripts before it reaches our scope, and polling `self.health` misses the zero-crossing (health is rewritten during the game-over sequence). + +HUD `1.2.0`, leaderboard `1.1.0` and AFK `1.0.1` require matching common `1.2.0+`. +Install the changed scripts together and restart the map. There is no new +native-plugin/backend dependency or full server-process restart requirement. +Run the [shared HUD checks](../../README.md#hud-validation); portable fixtures +do not replace T5 live loading, layout and lifecycle validation. + ## Module map | File | Role | Fires notifies | Reads notifies | |---|---|---|---| -| `tws_t5_common` | Shared config, HUD helpers, `.help` registry, spawn-handler registry, single connect listener + dispatcher | — | `say` | -| `tws_t5_hud` | Timer row, zombie count, health bar | `hud_round_changed`, `hud_match_started`, `hud_game_over` | (internal) | +| `tws_t5_common` | Shared config, HUD helpers, paginated `.help`, service window, spawn-handler registry, single connect listener + dispatcher | — | `say`, game-end notifications | +| `tws_t5_hud` | Upper-right core: branding, timer row, zombie count, HP and health bar | `hud_round_changed`, `hud_match_started`, `hud_game_over` | (internal) | | `tws_t5_afk` | `.afk` / `.back` chat toggle, owns its [AFK] indicator | — | — | | `tws_t5_join` | `.join` for spectators who have not played on this connection | — | `say`, `end_game`, `game_ended`, `intermission` | | `tws_t5_lock` | Lobby `g_password` lock at milestone round (`tws_lock_round`, default 20) | — | `hud_round_changed`, `hud_game_over` | diff --git a/t5/scripts/README.md b/t5/scripts/README.md index 38e87a5..d720f74 100644 --- a/t5/scripts/README.md +++ b/t5/scripts/README.md @@ -10,8 +10,8 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md). | File | Role | |---|---| -| [tws_t5_common.gsc](tws_t5_common.gsc) | Shared config, HUD helpers, `.help`, connect/spawn registries, single `connected` listener + dispatcher | -| [tws_t5_hud.gsc](tws_t5_hud.gsc) | Top-left Discord URL, timers, zombie count, health bar | +| [tws_t5_common.gsc](tws_t5_common.gsc) | Shared config, HUD helpers, paginated `.help`, service window, connect/spawn registries, single `connected` listener + dispatcher | +| [tws_t5_hud.gsc](tws_t5_hud.gsc) | Fixed upper-right core: branding, timers, zombie count, current/maximum HP and health bar | | [tws_t5_afk.gsc](tws_t5_afk.gsc) | `.afk` / `.back` chat toggle + `[AFK]` indicator | | [tws_t5_join.gsc](tws_t5_join.gsc) | `.join` lets newcomers spawn before the current round ends | | [tws_t5_lock.gsc](tws_t5_lock.gsc) | Lobby `g_password` lock at a milestone round | @@ -54,14 +54,24 @@ requests or journal writes, preserving existing strings exactly. Missing or invalid identities fail privately without changing points. Follow the shared [identity and deployment checks](../../README.md#bank-identity-validation). -## Persistent Discord text +## Core HUD and service window -The HUD shows `https://discord.tws.lol` in small muted-gray text -at the top-left, including for newcomers waiting in spectator mode. The label -is created once per connection and persists across death and respawn. The -gameplay HUD starts one line lower, keeping timers, help and records clear. -Install HUD `1.1.2` with common `1.1.0` or later, then restart the map; no plugin -update or full server-process restart is required. +The fixed upper-right core shows `TWS ZOMBIES`, `discord.tws.lol`, side-by-side +match/round timers, zombie count, current/maximum HP and a full-width health bar. +HP stays visible at full health. A reserved footer holds the AFK indicator; +there is no co-op name row. The core is created once per connection, including +for spectator newcomers with placeholders until values are available. Creation +does not seed the match clock. It persists across death and respawn. +Clocks, health and zombie-count displays freeze at game over. Health fallbacks +only affect the display and never modify gameplay max health. + +Common provides one fixed-height service window beneath the core for help or +records. Services never move or resize the core. They remain private and work +without the HUD module installed. See the [shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6). + +Install HUD `1.2.0`, leaderboard `1.1.0` and AFK `1.0.1` with common `1.2.0+`. +Update the installed scripts together and restart the map; this change requires +no new plugin/backend dependency or full server-process restart. ## Map record announcements @@ -83,8 +93,10 @@ apply to both games. Type `.help` or `.commands` in chat to see available player commands and a short description. The response is visible only to the requesting player and works while spectating, before the first spawn. Commands are case-insensitive. -Help appears below the health bar for 12 seconds, clear of the timers, zombie -counter and health display. Typing either command again restarts that display time. +Help uses the service window below the core. It shows four commands per page, +advances automatically every 12 seconds and closes after the last page. The +panel retains its height on short pages. Typing either command again returns to +page one with a fresh timer. | Command | Effect | |---|---| @@ -93,12 +105,17 @@ counter and health display. Typing either command again restarts that display ti | `.back` | Return from AFK | | `.join` | Join mid-round (newcomers only) | | `.leaders` | Show the map record again (leaderboard module required) | +| `.b` | Check bank balance (bank module required) | +| `.d ` | Deposit points (bank module required) | +| `.w ` | Withdraw points (bank module required) | -Each module registers its own help entries; removing AFK or join also removes -those entries from `.help` after a map restart. +Each module registers its own help entries. Commands follow the order above, +independent of initialization order; removing a module also removes its entries +from `.help` after a map restart. The enabled leaderboard module registers `.leaders`, which replays the latest cached map record privately. Repeating it restarts the record from the first page; -help temporarily hides the record until its own display ends. +help pauses the record's remaining page time and resumes it after the final +help page. `.leaders` during help replaces the pending record replay from page one. ## Dependency graph @@ -148,16 +165,18 @@ graph BT ## tws_t5_common.gsc -Hub file. Shared config, HUD helpers, spawn-handler registry, and the +Hub file. Shared config, HUD helpers, service window, spawn-handler registry, and the single `level waittill("connected")` in the fleet. Adding a second connect listener anywhere else crashes the T5 SP handshake. **Features** -- Shared HUD constants: `level.hud_x`, `hud_y`, `hud_row_*`, `hud_col_*`, - `hud_font_*`, `hud_bar_*`, `hud_c_*`, `hud_rate`, `hud_health_threshold` +- Shared HUD constants for panel geometry, rows/columns, fonts, health bar, + colors and update rate - Spawn-handler registry - Connection-handler registry for optional modules that serve spectator newcomers -- `.help` listener and help registry populated by installed command modules +- `.help` / `.commands` listener and ordered help registry populated by installed modules +- One reusable service window per player, with help/record pagination, + interruption/resume, request replacement and disconnect/game-end cleanup - Connect listener + per-player dispatch: iterates `level.tws_spawn_handlers` after `spawned_player` and threads each on the player @@ -172,6 +191,9 @@ connect listener anywhere else crashes the T5 SP handshake. | `register_help_command(command, description)` | Adds a player command to `.help`; call from the owning module's `init()` | | `make_text_elem(p, x, y, ax, s, c)`| `NewClientHudElem` text element with black-glow outline | | `make_shader_elem(p, x, y)` | `NewClientHudElem` shader element | +| `make_panel_text(p, x, y, ax, s, c)` | Text positioned relative to the fixed upper-right core origin | +| `make_panel_shader(p, x, y, w, h, c, alpha, sort)` | Background/bar shader positioned relative to the core origin | +| `show_record(snapshot)` | Replace the player's record service request; an undefined snapshot shows unavailable | | `destroy_on_disconnect(elems)` | Waits on `disconnect`, destroys the array | | `int_dvar_or(name, fallback)` | Parse an integer dvar with default | | `format_mmss(elapsed_ms)` | Format milliseconds as `M:SS` | @@ -180,27 +202,33 @@ connect listener anywhere else crashes the T5 SP handshake. **Live validation (requires a T5 server)** -1. Type `.help` and `.HELP` while playing and while spectating before the first - spawn. Verify all four descriptions appear below the health bar, only for the - requesting player. Take damage so health numbers are visible and check that - they do not cover the help text. +1. Type `.help`, `.HELP` and `.commands` while playing and spectating before the + first spawn. Verify four commands per page in the service window, private to + the requesting player. Check that full and damaged HP remain visible above it. 2. Remove AFK or join, restart the map, and verify its entries disappear while `.help` and the remaining commands still work. Restore the scripts afterward. -3. Repeat `.help` before 12 seconds elapse; verify one list stays visible for - 12 seconds after the latest request, then hides. Reopen it after expiry, - die/respawn and reconnect; verify no duplicate text or leftover help elements. +3. Verify pages advance every 12 seconds and close after the last page. Repeat + `.help` on a later page and check that it restarts page one. Interrupt a record + with help and verify remaining page time resumes; repeat `.leaders` during + help and verify only the latest replay starts from page one. +4. Reopen after expiry, die/respawn and reconnect; verify no duplicate text or + leftover elements. Repeat without the HUD module. Follow the complete + [HUD validation guide](../../README.md#hud-validation) before rollout. --- ## tws_t5_hud.gsc -Bottom-left cluster. Per-player elements spawn via `::hud_start`. +Fixed upper-right core panel. Per-player elements are created on connection; +gameplay updates start through the existing spawn lifecycle. **Features** - Match timer + round timer (round resets each round, both freeze on game over) -- Player-name row (only visible in co-op) +- `TWS ZOMBIES` / `discord.tws.lol` header and reserved AFK footer - Zombies-remaining counter -- Health bar with green / amber / red ramp; numbers fade in below full HP +- Full-width health bar with green / amber / red ramp; current/maximum HP + remains visible at full health +- Spectator placeholders without starting the match clock on connection - Round-transition and game-over notify machinery **Provides** @@ -214,22 +242,24 @@ Bottom-left cluster. Per-player elements spawn via `::hud_start`. Spawn handler registered: `::hud_start` — threads `hud_timer_row`, `hud_zombie_counter`, `hud_health_display`. +Connection handler registered: `::hud_connect` — creates the core with placeholders +without seeding clocks; `hud_start` calls it as a fallback if needed. **Depends on** -- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_text_elem`, - `make_shader_elem`, `destroy_on_disconnect`, `format_mmss`, - `register_spawn_handler` +- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_panel_text`, + `make_panel_shader`, `destroy_on_disconnect`, `format_mmss`, + `register_spawn_handler`, `register_connect_handler` - Every `level.hud_*` config value from `tws_t5_common` - Stock BO1 state: `level.round_number`, `level.zombie_total`, - `level.players`, `self.health`, `self.maxhealth`, `self.name` / - `self.playername`, AI entities' `.animname` and `.ignore_enemy_count` + `level.players`, `self.health`, `self.maxhealth`, AI entities' `.animname` + and `.ignore_enemy_count` --- ## tws_t5_afk.gsc -Self-invoked pause mode. Owns the `[AFK]` indicator element entirely — HUD -module has zero knowledge of AFK. +Self-invoked pause mode. Owns the `[AFK]` indicator element, positioned in the +core's reserved footer through common layout values. It does not require HUD. **Features** - Chat commands `.afk` and `.back` (via `level waittill("say")` — not @@ -247,10 +277,9 @@ module has zero knowledge of AFK. - Level var: `level.afk_grace_seconds` (default 2) **Depends on** -- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_text_elem`, +- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_panel_text`, `destroy_on_disconnect`, `register_spawn_handler`, `register_help_command` -- Shared config: `level.hud_x`, `level.hud_row_match`, - `level.hud_font_small`, `level.hud_c_afk` +- Shared config: core footer position, `level.hud_font_small`, `level.hud_c_afk` - Stock BO1: `enableInvulnerability`, `disableInvulnerability`, `freezeControls`, `.ignoreme`, `level waittill("say", message, player)` diff --git a/t5/scripts/tws_t5_afk.gsc b/t5/scripts/tws_t5_afk.gsc index 50ef3a0..36aeb75 100644 --- a/t5/scripts/tws_t5_afk.gsc +++ b/t5/scripts/tws_t5_afk.gsc @@ -50,9 +50,9 @@ afk_spawn() { self endon("disconnect"); - afk = scripts\sp\tws_t5_common::make_text_elem( - self, level.hud_x + 42, level.hud_row_match, - "left", level.hud_font_small, level.hud_c_afk); + afk = scripts\sp\tws_t5_common::make_panel_text( + self, level.hud_pad, level.hud_row_afk, + "left", level.hud_font_afk, level.hud_c_afk); afk SetText("[AFK]"); afk.alpha = 0; self.hud_afk_elem = afk; diff --git a/t5/scripts/tws_t5_common.gsc b/t5/scripts/tws_t5_common.gsc index 5bf7f81..c43cdc5 100644 --- a/t5/scripts/tws_t5_common.gsc +++ b/t5/scripts/tws_t5_common.gsc @@ -3,6 +3,11 @@ #include maps\_utility; #include common_scripts\utility; +main() +{ + precacheShader("white"); +} + init() { if (GetDvar(#"zombiemode") != "1") @@ -11,6 +16,9 @@ init() common_config(); register_help_command(".help", "List available commands"); + level thread service_game_over_via("end_game"); + level thread service_game_over_via("game_ended"); + level thread service_game_over_via("intermission"); level thread help_chat_listener(); level thread on_player_connect_dispatch(); } @@ -20,41 +28,50 @@ common_config() if (isDefined(level.hud_x)) return; - // Anchor the gameplay stack below the persistent Discord line. + // Existing generic helpers retain their screen-anchor conventions. Panels + // use their own right-edge origin and never push stock HUD areas around. level.hud_x = 10; level.hud_y = 30; - - level.hud_row_match = level.hud_y; - level.hud_row_round = level.hud_y + 15; - level.hud_row_name = level.hud_y + 32; - level.hud_row_count = level.hud_y + 54; - level.hud_row_bar = level.hud_y + 84; - - level.hud_font_small = 1.4; - level.hud_font_large = 2.0; - level.hud_font_mid = 1.6; - - level.hud_bar_width = 120; - level.hud_bar_height = 8; - - level.hud_col_count = level.hud_x + 90; - level.hud_col_name = level.hud_x + 60; - level.hud_col_health = level.hud_x + level.hud_bar_width + 12; - - level.hud_rate = 0.1; - - // green/amber/red ramp is health-only - level.hud_c_label = (0.85, 0.85, 0.85); - level.hud_c_dim = (0.55, 0.55, 0.55); - level.hud_c_count = (1, 1, 1); - level.hud_c_name = (0.4, 0.9, 0.5); - level.hud_c_high = (0.2, 1, 0.4); - level.hud_c_mid = (1, 0.8, 0); - level.hud_c_low = (1, 0.2, 0.2); - level.hud_c_afk = (1, 0.8, 0.2); - - // numbers fade in below this fraction of max hp + // Retain the legacy fields for consumers of the original common config. + // The panel renderers below use dedicated row/column offsets instead. + level.hud_row_match = level.hud_y; + level.hud_row_round = level.hud_y + 15; + level.hud_row_name = level.hud_y + 32; + level.hud_row_count = level.hud_y + 54; + level.hud_col_count = level.hud_x + 90; + level.hud_col_name = level.hud_x + 60; + level.hud_col_health = level.hud_x + 120 + 12; level.hud_health_threshold = 1.0; + level.hud_panel_right = 10; + level.hud_panel_top = 42; + level.hud_panel_width = 220; + level.hud_core_height = 100; + level.hud_service_y = 108; + level.hud_service_height = 124; + level.hud_pad = 10; + level.hud_row_header = 10; + level.hud_row_timers = 34; + level.hud_row_stats = 59; + level.hud_row_bar = 80; + level.hud_row_afk = 86; + + level.hud_font_small = 1.1; + level.hud_font_large = 1.6; + level.hud_font_mid = 1.25; + level.hud_font_afk = 1.0; + level.hud_bar_width = 200; + level.hud_bar_height = 4; + level.hud_rate = 0.1; + level.hud_c_panel = (0.07, 0.10, 0.08); + level.hud_panel_alpha = 0.72; + level.hud_c_label = (0.72, 0.77, 0.74); + level.hud_c_dim = (0.65, 0.70, 0.67); + level.hud_c_count = (0.93, 0.96, 0.94); + level.hud_c_name = (0.65, 0.85, 0.70); + level.hud_c_high = (0.65, 0.85, 0.70); + level.hud_c_mid = (1, 0.8, 0); + level.hud_c_low = (1, 0.2, 0.2); + level.hud_c_afk = (1, 0.8, 0.2); if (!isDefined(level.tws_spawn_handlers)) level.tws_spawn_handlers = []; @@ -111,58 +128,356 @@ help_chat_listener() } } +// A single per-player controller owns service priority, pages and expiry. show_help() { - // Cache the text per connection; repeated SetText calls consume config strings. - if (!isDefined(self.tws_help_elems)) - { - elems = []; - // Use the same anchors as the HUD, with room below the health numbers. - y = level.hud_row_bar + level.hud_bar_height + 20; - title = make_text_elem(self, level.hud_x, y, - "left", level.hud_font_mid, level.hud_c_afk); - title SetText("Commands"); - elems[elems.size] = title; + if (isDefined(level.tws_service_ended)) + return; + self service_ensure(); + state = self.tws_service; + service_advance(state, getTime()); + state.generation++; + state.help = service_request("help", service_page_count(state.commands.size, 4), state.generation); + service_render(state); +} +// An undefined snapshot is the explicit unavailable view for manual requests. +show_record(snapshot) +{ + if (isDefined(level.tws_service_ended)) + return; + self service_ensure(); + state = self.tws_service; + service_advance(state, getTime()); + count = 0; + if (isDefined(snapshot)) + count = snapshot.lines.size; + state.generation++; + state.record = service_request("record", service_page_count(count, 3), state.generation); + state.record.snapshot = snapshot; + service_render(state); +} + +service_page_count(count, rows) +{ + return int((max(count, 1) + rows - 1) / rows); +} + +service_request(kind, pages, generation) +{ + request = spawnStruct(); + request.kind = kind; + request.generation = generation; + request.page = 0; + request.pages = pages; + request.remaining = 12000; + return request; +} + +service_current(state) +{ + if (isDefined(state.help)) + return state.help; + return state.record; +} + +service_advance(state, now) +{ + elapsed = max(0, now - state.updated_at); + state.updated_at = now; + request = service_current(state); + if (!isDefined(request) || state.closed) + return; + + // Only the visible request consumes time. Help leaves the record untouched. + request.remaining -= elapsed; + if (request.remaining > 0) + return; + request.page++; + request.remaining = 12000; + if (request.page >= request.pages) + { + if (request.kind == "help") + state.help = undefined; + else + state.record = undefined; + } +} + +// Stable order is independent of module initialization order; absent commands +// stay absent. Unknown future commands follow the built-in ones in registry order. +help_rank(command) +{ + if (command == ".help") return 0; + if (command == ".afk") return 1; + if (command == ".back") return 2; + if (command == ".join") return 3; + if (command == ".leaders") return 4; + if (command == ".b") return 5; + if (command.size >= 3 && getSubStr(command, 0, 3) == ".d ") return 6; + if (command.size >= 3 && getSubStr(command, 0, 3) == ".w ") return 7; + return 8; +} + +help_entries() +{ + entries = []; + if (!isDefined(level.tws_help_commands)) + return entries; + for (rank = 0; rank <= 8; rank++) + { for (i = 0; i < level.tws_help_commands.size; i++) { entry = level.tws_help_commands[i]; - elem = make_text_elem(self, level.hud_x, y + 22 + i * 18, - "left", level.hud_font_small, level.hud_c_count); - elem SetText("^3" + entry.command + "^7 - " + entry.description); - elems[elems.size] = elem; + if (help_rank(entry.command) == rank) + entries[entries.size] = entry; } - - self.tws_help_elems = elems; - self thread destroy_on_disconnect(elems); } - - for (i = 0; i < self.tws_help_elems.size; i++) - { - elem = self.tws_help_elems[i]; - elem.alpha = 1; - } - - // Only the expiry timer is replaced; the say listener and cleanup stay alive. - self notify("tws_help_refresh"); - self thread hide_help_after_delay(); + return entries; } -hide_help_after_delay() +help_command_label(command) +{ + rank = help_rank(command); + if (rank == 6) return ".d "; + if (rank == 7) return ".w "; + return command; +} + +help_description(entry) +{ + rank = help_rank(entry.command); + if (rank == 0) return "Show commands"; + if (rank == 1) return "Enable protection"; + if (rank == 2) return "Return to play"; + if (rank == 3) return "Join as newcomer"; + if (rank == 4) return "View records"; + if (rank == 5) return "Check balance"; + if (rank == 6) return "Deposit points"; + if (rank == 7) return "Withdraw points"; + return entry.description; +} + +service_ensure() +{ + if (isDefined(self.tws_service)) + return; + ensure_config(); + state = spawnStruct(); + state.elems = []; + state.commands = help_entries(); + state.help = undefined; + state.record = undefined; + state.closed = false; + state.updated_at = getTime(); + state.rendered = false; + state.generation = 0; + state.last_generation = 0; + state.last_page = -1; + state.record_text = []; + state.help_page = -1; + state.rows = []; + self.tws_service = state; + + y = level.hud_service_y; + state.background = make_panel_shader(self, 0, y, level.hud_panel_width, + level.hud_service_height, level.hud_c_panel, level.hud_panel_alpha, 0); + state.elems[state.elems.size] = state.background; + state.help_title = service_text(self, state, 10, 10, "left", 1.1, "COMMANDS", level.hud_c_label); + state.record_title = service_text(self, state, 10, 10, "left", 1.1, "MAP RECORD", level.hud_c_label); + state.help_footer = service_text(self, state, 10, 108, "left", 0.9, + "Four commands per page", level.hud_c_dim); + state.record_footer = service_text(self, state, 10, 108, "left", 0.9, + "Briefly shown - .leaders to view again", level.hud_c_dim); + state.page_number = service_text(self, state, 178, 10, "right", 1.1, "", level.hud_c_dim); + state.page_slash = service_text(self, state, 184, 10, "center", 1.1, "/", level.hud_c_dim); + state.page_total = service_text(self, state, 210, 10, "right", 1.1, "", level.hud_c_dim); + // Two fixed text columns keep the HUD element budget independent of the + // command count. Strings change only at page boundaries, never per tick. + state.command_back = make_panel_shader(self, 10, y + 30, 65, 68, + (0.16, 0.19, 0.17), 0.9, 5); + state.elems[state.elems.size] = state.command_back; + state.command_column = service_text(self, state, 14, 31, + "left", 1.0, "", level.hud_c_name); + state.description_column = service_text(self, state, 81, 31, + "left", 1.0, "", level.hud_c_count); + state.round_label = service_text(self, state, 10, 32, "left", 1.1, "ROUND", level.hud_c_label); + state.round_number = service_text(self, state, 54, 27, "left", 1.9, "", level.hud_c_count); + state.holders_label = service_text(self, state, 10, 52, "left", 1.0, "RECORD HOLDERS", level.hud_c_label); + for (i = 0; i < 3; i++) + state.rows[i] = service_text(self, state, 10, 65 + i * 13, + "left", 1.1, "", level.hud_c_count); + state.empty = service_text(self, state, 10, 40, "left", 1.1, + "No recorded high round yet", level.hud_c_count); + state.unavailable = service_text(self, state, 10, 40, "left", 1.1, + "Map record unavailable", level.hud_c_count); + for (i = 0; i < state.elems.size; i++) + { + state.elems[i].alpha = 0; + // Keep temporary private services out of the archived HUD bucket. + // Hidden elements still occupy slots; do not allocate one per page. + state.elems[i].archived = false; + } + + self thread service_loop(state); + self thread service_disconnect(state); + self thread service_end_game(state); +} + +service_text(player, state, x, y, align, scale, text, colour) +{ + elem = make_panel_text(player, x, level.hud_service_y + y, align, scale, colour); + elem.alpha = 0; + if (text != "") + elem SetText(text); + state.elems[state.elems.size] = elem; + return elem; +} + +service_render(state) +{ + if (state.closed) + return; + request = service_current(state); + page = -1; + generation = 0; + if (isDefined(request)) + { + page = request.page; + generation = request.generation; + } + if (state.rendered && state.last_generation == generation && state.last_page == page) + return; + state.rendered = true; + state.last_generation = generation; + state.last_page = page; + for (i = 0; i < state.elems.size; i++) + state.elems[i].alpha = 0; + if (!isDefined(request)) + return; + + state.background.alpha = level.hud_panel_alpha; + state.page_number SetValue(page + 1); + state.page_total SetValue(request.pages); + state.page_number.alpha = 1; + state.page_slash.alpha = 1; + state.page_total.alpha = 1; + if (request.kind == "help") + { + state.help_title.alpha = 1; + state.help_footer.alpha = 1; + if (state.help_page != page) + { + command_text = ""; + description_text = ""; + for (i = 0; i < 4; i++) + { + index = page * 4 + i; + if (index < state.commands.size) + { + if (i > 0) + { + command_text += "\n"; + description_text += "\n"; + } + entry = state.commands[index]; + command_text += help_command_label(entry.command); + description_text += help_description(entry); + } + } + state.command_column SetText(command_text); + state.description_column SetText(description_text); + state.help_page = page; + } + state.command_back.alpha = 0.9; + state.command_column.alpha = 1; + state.description_column.alpha = 1; + return; + } + + state.record_title.alpha = 1; + state.record_footer.alpha = 1; + snapshot = request.snapshot; + if (!isDefined(snapshot)) + { + state.unavailable.alpha = 1; + return; + } + if (snapshot.round == 0) + { + state.empty.alpha = 1; + return; + } + state.round_label.alpha = 1; + state.round_number SetValue(snapshot.round); + state.round_number.alpha = 1; + state.holders_label.alpha = 1; + for (i = 0; i < 3; i++) + { + index = page * 3 + i; + if (index < snapshot.lines.size) + { + text = snapshot.lines[index]; + // Skip identical text when replaying a page or resuming after help. + if (!isDefined(state.record_text[i]) || state.record_text[i] != text) + { + state.rows[i] SetText(text); + state.record_text[i] = text; + } + state.rows[i].alpha = 1; + } + } +} + +service_loop(state) { self endon("disconnect"); - self endon("tws_help_refresh"); - - wait 12; - - for (i = 0; i < self.tws_help_elems.size; i++) + level endon("tws_service_game_over"); + for (;;) { - elem = self.tws_help_elems[i]; - elem.alpha = 0; + service_advance(state, getTime()); + service_render(state); + wait 0.1; } } -// only connect listener in the fleet; a second one crashes the T5 SP handshake +service_disconnect(state) +{ + self waittill("disconnect"); + service_destroy(state); +} + +service_end_game(state) +{ + self endon("disconnect"); + level waittill("tws_service_game_over"); + service_destroy(state); +} + +service_destroy(state) +{ + if (state.closed) + return; + state.closed = true; + state.help = undefined; + state.record = undefined; + for (i = 0; i < state.elems.size; i++) + { + if (isDefined(state.elems[i])) + state.elems[i] Destroy(); + } + state.elems = []; +} + +service_game_over_via(event) +{ + level endon("tws_service_game_over"); + level waittill(event); + level.tws_service_ended = true; + level notify("tws_service_game_over"); +} + +// The only connected listener; modules use the shared registries. on_player_connect_dispatch() { for (;;) @@ -238,6 +553,32 @@ make_shader_elem(player, x, y) return elem; } +// Panel x/y are local to the fixed upper-right core origin. A right screen +// anchor keeps the panel on-screen across aspect ratios; negative offsets grow +// left from that edge. Keep each engine's existing vertical anchor convention. +make_panel_text(player, x, y, align_x, scale, colour) +{ + elem = make_text_elem(player, x - level.hud_panel_width - level.hud_panel_right, + level.hud_panel_top + y, align_x, scale, colour); + elem.horzAlign = "right"; + elem.sort = 20; + elem.archived = true; + return elem; +} + +make_panel_shader(player, x, y, width, height, colour, alpha, sort) +{ + elem = make_shader_elem(player, x - level.hud_panel_width - level.hud_panel_right, + level.hud_panel_top + y); + elem.horzAlign = "right"; + elem.color = colour; + elem.alpha = alpha; + elem.sort = sort; + elem.archived = true; + elem SetShader("white", width, height); + return elem; +} + destroy_on_disconnect(elems) { self waittill("disconnect"); diff --git a/t5/scripts/tws_t5_hud.gsc b/t5/scripts/tws_t5_hud.gsc index 40adb88..dc6eebb 100644 --- a/t5/scripts/tws_t5_hud.gsc +++ b/t5/scripts/tws_t5_hud.gsc @@ -1,4 +1,4 @@ -/* tws_t5_hud.gsc — top-left Discord URL, timers, zombie count and health bar. */ +/* tws_t5_hud.gsc - fixed upper-right core HUD, visible from connection. */ #include maps\_utility; #include common_scripts\utility; @@ -13,34 +13,90 @@ init() level thread round_watcher(); level thread game_over_watcher(); + scripts\sp\tws_t5_common::register_connect_handler(::hud_connect); scripts\sp\tws_t5_common::register_spawn_handler(::hud_start); - scripts\sp\tws_t5_common::register_connect_handler(::hud_discord); } -// One static label per connection, including newcomers waiting to spawn. -hud_discord() +// Connection construction never seeds clocks or reads player gameplay state. +// The first-spawn fallback also handles a spawn before the connect callback. +hud_connect() { self endon("disconnect"); - label = scripts\sp\tws_t5_common::make_text_elem( - self, 10, 10, "left", 1.2, (0.65, 0.65, 0.65)); - label SetText("https://discord.tws.lol"); + if (isDefined(self.tws_hud_elems)) + return; - elems = []; - elems[elems.size] = label; - self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems); + self.tws_hud_elems = []; + background = scripts\sp\tws_t5_common::make_panel_shader( + self, 0, 0, level.hud_panel_width, level.hud_core_height, + level.hud_c_panel, level.hud_panel_alpha, 0); + self.tws_hud_elems[self.tws_hud_elems.size] = background; + + self hud_text(level.hud_pad, level.hud_row_header, "left", + level.hud_font_small, level.hud_c_name, "TWS ZOMBIES"); + self hud_text(level.hud_panel_width - level.hud_pad, level.hud_row_header, + "right", level.hud_font_small, level.hud_c_dim, "discord.tws.lol"); + + self hud_text(level.hud_pad, level.hud_row_timers, "left", + level.hud_font_small, level.hud_c_label, "MATCH"); + self.tws_hud_match = self hud_text(104, level.hud_row_timers - 2, "right", + level.hud_font_mid, level.hud_c_count, "--:--"); + self hud_text(114, level.hud_row_timers, "left", + level.hud_font_small, level.hud_c_label, "ROUND"); + self.tws_hud_round = self hud_text(level.hud_panel_width - level.hud_pad, + level.hud_row_timers - 2, "right", + level.hud_font_mid, level.hud_c_count, "--:--"); + + self hud_text(level.hud_pad, level.hud_row_stats, "left", + level.hud_font_small, level.hud_c_label, "ZOMBIES"); + self.tws_hud_count = self hud_text(67, level.hud_row_stats, "left", + level.hud_font_mid, level.hud_c_count, "--"); + self hud_text(132, level.hud_row_stats, "left", + level.hud_font_small, level.hud_c_label, "HP"); + + // Independent numeric elements avoid a new config string on every tick. + self.tws_hud_health = self hud_text(174, level.hud_row_stats, "right", + level.hud_font_mid, level.hud_c_count, "--"); + self.tws_hud_slash = self hud_text(181, level.hud_row_stats, "center", + level.hud_font_mid, level.hud_c_count, "/"); + self.tws_hud_maxhealth = self hud_text(level.hud_panel_width - level.hud_pad, + level.hud_row_stats, "right", level.hud_font_mid, level.hud_c_count, "--"); + + track = scripts\sp\tws_t5_common::make_panel_shader( + self, level.hud_pad, level.hud_row_bar, + level.hud_bar_width, level.hud_bar_height, (0.18, 0.22, 0.19), 0.8, 10); + self.tws_hud_elems[self.tws_hud_elems.size] = track; + self.tws_hud_bar = scripts\sp\tws_t5_common::make_panel_shader( + self, level.hud_pad, level.hud_row_bar, + level.hud_bar_width, level.hud_bar_height, level.hud_c_high, 0, 11); + self.tws_hud_elems[self.tws_hud_elems.size] = self.tws_hud_bar; + + self thread scripts\sp\tws_t5_common::destroy_on_disconnect(self.tws_hud_elems); +} + +hud_text(x, y, align, scale, colour, text) +{ + elem = scripts\sp\tws_t5_common::make_panel_text(self, x, y, align, scale, colour); + elem SetText(text); + self.tws_hud_elems[self.tws_hud_elems.size] = elem; + return elem; } hud_start() { self endon("disconnect"); + self hud_connect(); + if (isDefined(self.tws_hud_bound) || is_true(level.tws_ended)) + return; + + self.tws_hud_bound = true; self thread hud_timer_row(); self thread hud_zombie_counter(); self thread hud_health_display(); } -// BO1 maps use different end-of-match notify names; race all three +// Race the map-dependent game-over notifications once at level scope. game_over_watcher() { level thread game_over_via("end_game"); @@ -52,17 +108,14 @@ game_over_via(notify_name) { level endon("hud_game_over"); level waittill(notify_name); + level.tws_ended = true; level notify("hud_game_over"); } -/* - Watch level.round_number rather than a notify — the notify names vary - between BO1 map scripts, the variable doesn't. Seed on OBSERVED - transitions only: many maps pre-set round_number = 1 at level init - and we don't want getTime() captured there (see NOTES.md). -*/ +// Pre-set round 1 is seeded on first spawn; only observed transitions seed here. round_watcher() { + level endon("hud_game_over"); last = 0; if (isDefined(level.round_number)) @@ -92,7 +145,7 @@ hud_timer_row() { self endon("disconnect"); - // fallback seed for the round-1-already-active case (round_watcher misses it) + // A visible panel for a waiting newcomer must not start a pre-set round 1. if (!isDefined(level.match_start_time) && isDefined(level.round_number) && level.round_number >= 1) @@ -102,59 +155,17 @@ hud_timer_row() level notify("hud_match_started"); } - match = scripts\sp\tws_t5_common::make_text_elem( - self, level.hud_x, level.hud_row_match, - "left", level.hud_font_small, level.hud_c_dim); - match.alpha = 0; // revealed by match_timer_bind when round 1 opens - - round = scripts\sp\tws_t5_common::make_text_elem( - self, level.hud_x, level.hud_row_round, - "left", level.hud_font_small, level.hud_c_dim); + round = self.tws_hud_round; round SetTimerUp(0); - // name only shows in co-op — your own name is noise in solo - name = scripts\sp\tws_t5_common::make_text_elem( - self, level.hud_x, level.hud_row_name, - "left", level.hud_font_small, level.hud_c_name); - - if (isDefined(self.name)) - name SetText(self.name); - else - name SetText(self.playername); - - name.alpha = 0; - - elems = []; - elems[elems.size] = match; - elems[elems.size] = round; - elems[elems.size] = name; - self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems); - self thread reset_timer_each_round(round); - self thread match_timer_bind(match); - self thread freeze_timers_on_game_over(match, round); - - coop = -1; - - for (;;) - { - is_coop = 0; - - if (isDefined(level.players) && level.players.size > 1) - is_coop = 1; - - if (is_coop != coop) - { - name.alpha = is_coop; - coop = is_coop; - } - - wait (1); - } + self thread match_timer_bind(self.tws_hud_match); + self thread freeze_timers_on_game_over(self.tws_hud_match, round); } reset_timer_each_round(timer) { + level endon("hud_game_over"); self endon("disconnect"); for (;;) @@ -166,26 +177,20 @@ reset_timer_each_round(timer) } } -// SetTimerUp only ticks with arg 0 on T5 SP; late joiners start at 0 +// T5 SP only ticks with a zero seed; late joiners retain that engine limitation. match_timer_bind(elem) { self endon("disconnect"); + level endon("hud_game_over"); if (!isDefined(level.match_start_time)) level waittill("hud_match_started"); if (isDefined(elem)) - { elem SetTimerUp(0); - elem.alpha = 1; - } } -/* - Level-scope trigger only — per-player death/health polling is intercepted - by the zombiemode scripts. Freeze value math relies on the timers being - anchored at first spawn / first round transition, not level init. -*/ +// The panel remains visible at game over, with authoritative frozen clocks. freeze_timers_on_game_over(match_elem, round_elem) { self endon("disconnect"); @@ -201,27 +206,16 @@ freeze_timers_on_game_over(match_elem, round_elem) hud_zombie_counter() { self endon("disconnect"); + level endon("hud_game_over"); - label = scripts\sp\tws_t5_common::make_text_elem( - self, level.hud_x, level.hud_row_count, - "left", level.hud_font_large, level.hud_c_label); - label SetText("Zombies"); - - count = scripts\sp\tws_t5_common::make_text_elem( - self, level.hud_col_count, level.hud_row_count, - "left", level.hud_font_large, level.hud_c_count); - count SetValue(0); - - elems = []; - elems[elems.size] = label; - elems[elems.size] = count; - self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems); - + count = self.tws_hud_count; last = -1; for (;;) { - remaining = get_enemy_count() + level.zombie_total; + remaining = get_enemy_count(); + if (isDefined(level.zombie_total)) + remaining += max(0, level.zombie_total); if (remaining != last) { @@ -253,117 +247,64 @@ get_enemy_count() hud_health_display() { self endon("disconnect"); - - if (!isDefined(self.maxhealth) || self.maxhealth <= 0) - self.maxhealth = 100; + level endon("hud_game_over"); bar_width = level.hud_bar_width; - - // -2 on both axes so the back frames the bar with 2px padding - back = scripts\sp\tws_t5_common::make_shader_elem(self, level.hud_x - 2, level.hud_row_bar - 2); - back.alpha = 0.5; - back.color = (0, 0, 0); - back SetShader("black", bar_width + 4, level.hud_bar_height + 4); - - bar = scripts\sp\tws_t5_common::make_shader_elem(self, level.hud_x, level.hud_row_bar); - bar.color = level.hud_c_high; - bar SetShader("white", bar_width, level.hud_bar_height); - - /* - "87/160" is three elements, not a rebuilt string. SetText allocates - a config string per call; per-tick rebuilds crash the server. - cur is right-aligned so digits grow leftward and the slash + max - never shift. - */ - cur = scripts\sp\tws_t5_common::make_text_elem( - self, level.hud_col_health + 26, level.hud_row_bar + 3, - "right", level.hud_font_mid, level.hud_c_high); - cur SetValue(self.health); - - slash = scripts\sp\tws_t5_common::make_text_elem( - self, level.hud_col_health + 30, level.hud_row_bar + 3, - "center", level.hud_font_mid, level.hud_c_high); - slash SetText("/"); - - max_hp = scripts\sp\tws_t5_common::make_text_elem( - self, level.hud_col_health + 34, level.hud_row_bar + 3, - "left", level.hud_font_mid, level.hud_c_high); - max_hp SetValue(self.maxhealth); - - cur.alpha = 0; - slash.alpha = 0; - max_hp.alpha = 0; - - elems = []; - elems[elems.size] = back; - elems[elems.size] = bar; - elems[elems.size] = cur; - elems[elems.size] = slash; - elems[elems.size] = max_hp; - self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems); - - last_width = bar_width; + bar = self.tws_hud_bar; + cur = self.tws_hud_health; + slash = self.tws_hud_slash; + max_hp = self.tws_hud_maxhealth; + last_width = -1; last_health = -1; - last_max = -1; - last_shown = -1; + last_max = -1; for (;;) { - if (!isDefined(self.maxhealth) || self.maxhealth <= 0) - self.maxhealth = 100; - - frac = self.health / self.maxhealth; + // Display fallbacks must not alter gameplay health values. + maximum = 100; + if (isDefined(self.maxhealth) && self.maxhealth > 0) + maximum = self.maxhealth; + current = 0; + if (isDefined(self.health)) + current = max(0, self.health); + frac = current / (maximum * 1.0); if (frac > 1) frac = 1; - if (frac < 0) - frac = 0; - width = int(max(frac * bar_width, 1)); - if (width != last_width) { bar SetShader("white", width, level.hud_bar_height); last_width = width; } - colour = level.hud_c_high; + bar.alpha = 1; + if (frac == 0) + bar.alpha = 0; + colour = level.hud_c_high; if (frac <= 0.25) colour = level.hud_c_low; else if (frac <= 0.5) colour = level.hud_c_mid; - bar.color = colour; - cur.color = colour; - slash.color = colour; + bar.color = colour; + cur.color = colour; + slash.color = colour; max_hp.color = colour; - // numbers only show once you're hurt - show = 0; - - if (frac < level.hud_health_threshold) - show = 1; - - if (show != last_shown) + // Health numbers remain visible at full health. + if (current != last_health) { - cur.alpha = show; - slash.alpha = show; - max_hp.alpha = show; - last_shown = show; + cur SetValue(current); + last_health = current; } - if (self.health != last_health) + if (maximum != last_max) { - cur SetValue(self.health); - last_health = self.health; - } - - if (self.maxhealth != last_max) - { - max_hp SetValue(self.maxhealth); - last_max = self.maxhealth; + max_hp SetValue(maximum); + last_max = maximum; } wait (level.hud_rate); diff --git a/t5/scripts/tws_t5_leaderboard.gsc b/t5/scripts/tws_t5_leaderboard.gsc index 454a5a9..f6b2b25 100644 --- a/t5/scripts/tws_t5_leaderboard.gsc +++ b/t5/scripts/tws_t5_leaderboard.gsc @@ -124,120 +124,17 @@ lb_request(manual) level endon("game_ended"); level endon("intermission"); - snapshot = lb_cached_record(); - if (!isDefined(snapshot)) - { - if (manual) - self iPrintLnBold("^3Map record unavailable; try .leaders again shortly"); - return; - } // A manual replay replaces any welcome announcement still waiting to run. + // This also applies when the cache is unavailable. if (manual) self notify("tws_lb_requested"); - self lb_show_hud(snapshot); -} + snapshot = lb_cached_record(); + if (!isDefined(snapshot) && !manual) + return; -lb_show_hud(snapshot) -{ - // Stop the previous display and its cleanup threads before replacing it. - self notify("tws_lb_hud_done"); - if (isDefined(self.tws_lb_elems)) - lb_hud_destroy(self.tws_lb_elems); - self endon("disconnect"); - self endon("tws_lb_hud_done"); - - scripts\sp\tws_t5_common::ensure_config(); - // Match .help's position, typography and row spacing below the health bar. - y = level.hud_row_bar + level.hud_bar_height + 20; - elems = []; - title = scripts\sp\tws_t5_common::make_text_elem( - self, level.hud_x, y, "left", level.hud_font_mid, level.hud_c_afk); - title.alpha = 0; - if (snapshot.round == 0) - title SetText("No recorded high round yet"); - else - title SetText("Record: Round " + snapshot.round); - elems[elems.size] = title; - - for (i = 0; i < 3; i++) - { - row = scripts\sp\tws_t5_common::make_text_elem( - self, level.hud_x, y + 22 + i * 18, - "left", level.hud_font_small, level.hud_c_count); - row.alpha = 0; - elems[elems.size] = row; - } - - // Cleanup runs independently of the display and ends when it is replaced. - self.tws_lb_elems = elems; - self thread lb_hud_disconnect(elems); - self thread lb_hud_end_game(elems, "end_game"); - self thread lb_hud_end_game(elems, "game_ended"); - self thread lb_hud_end_game(elems, "intermission"); - - offset = 0; - for (;;) - { - // Text changes only once per page, never during the visibility loop. - for (i = 0; i < 3; i++) - { - row = elems[i + 1]; - if (offset + i < snapshot.lines.size) - row SetText("^3Held by: ^7" + snapshot.lines[offset + i]); - else - row SetText(""); - } - - remaining = 12; - while (remaining > 0) - { - visible = !self lb_hud_help_visible(); - for (i = 0; i < elems.size; i++) - elems[i].alpha = visible; - wait 0.1; - // Pause the page while .help occupies the same area. - if (visible) - remaining -= 0.1; - } - offset += 3; - if (offset >= snapshot.lines.size) - break; - } - lb_hud_destroy(elems); - self.tws_lb_elems = undefined; - self notify("tws_lb_hud_done"); -} - -lb_hud_help_visible() -{ - return isDefined(self.tws_help_elems) && self.tws_help_elems.size > 0 && - isDefined(self.tws_help_elems[0]) && self.tws_help_elems[0].alpha > 0; -} - -lb_hud_disconnect(elems) -{ - self endon("tws_lb_hud_done"); - self waittill("disconnect"); - lb_hud_destroy(elems); -} - -lb_hud_end_game(elems, event) -{ - self endon("disconnect"); - self endon("tws_lb_hud_done"); - level waittill(event); - lb_hud_destroy(elems); - self.tws_lb_elems = undefined; - self notify("tws_lb_hud_done"); -} - -lb_hud_destroy(elems) -{ - for (i = 0; i < elems.size; i++) - { - if (isDefined(elems[i])) - elems[i] Destroy(); - } + // Common owns pagination, help priority and the single service window. + // Undefined snapshots produce a private unavailable page for manual replay. + self scripts\sp\tws_t5_common::show_record(snapshot); } lb_refresh() @@ -480,22 +377,85 @@ lb_holder_lines(names) { lines = []; line = ""; + columns = 0; for (i = 0; i < names.size; i++) { - if (line != "" && line.size + names[i].size + 2 > 100) + name = lb_holder_name(names[i]); + width = lb_holder_width(name); + if (line != "" && columns + width + 2 > 16) { lines[lines.size] = line; line = ""; + columns = 0; } if (line != "") + { line += ", "; - line += names[i]; + columns += 2; + } + line += name; + columns += width; } if (line != "") lines[lines.size] = line; return lines; } +lb_holder_name(value) +{ + // The service has 200 virtual units at font scale 1.1. Use a conservative + // 16-column bound, counting non-ASCII glyphs twice, pending in-game metrics. + // A long name gets one bounded entry; no tied holder is dropped. + value = lb_display_text(value, 64); + if (value == "") + value = "Unknown player"; + if (lb_holder_width(value) <= 16) + return value; + + output = ""; + columns = 0; + for (i = 0; i < value.size; i++) + { + code = level.tws_lb_bytes[getSubStr(value, i, i + 1)]; + width = 1; + if (code >= 128) + width = 2; + if (columns + width > 13) + break; + count = lb_glyph_bytes(code); + output += getSubStr(value, i, i + count); + columns += width; + i += count - 1; + } + return output + "..."; +} + +lb_holder_width(value) +{ + columns = 0; + for (i = 0; i < value.size; i++) + { + code = level.tws_lb_bytes[getSubStr(value, i, i + 1)]; + if (code >= 128) + columns += 2; + else + columns++; + i += lb_glyph_bytes(code) - 1; + } + return columns; +} + +lb_glyph_bytes(code) +{ + if (code >= 240) + return 4; + if (code >= 224) + return 3; + if (code >= 192) + return 2; + return 1; +} + lb_display_text(value, limit) { output = ""; diff --git a/t6/scripts/NOTES.md b/t6/scripts/NOTES.md index ceaa763..030cc62 100644 --- a/t6/scripts/NOTES.md +++ b/t6/scripts/NOTES.md @@ -1,7 +1,7 @@ # T6 port notes T6 is ported from T5. Both source trees contain common, HUD, AFK, join, lock, -telemetry and leaderboard modules; neither includes bank or database modules. +telemetry, leaderboard and bank modules; neither includes direct-database modules. ## Differences retained for T6 and requested fixes @@ -9,7 +9,7 @@ telemetry and leaderboard modules; neither includes bank or database modules. | --- | --- | --- | | Paths | `maps/mp/_utility`, `scripts/zm/tws_t6_common` | T6 namespaces and requested filenames | | Loading | No T5 `zombiemode` dvar gate; each module has `main()` and `init()` | Zombies-only folder loading; avoid r5346 missing-main warnings | -| HUD positioning | Fullscreen screen anchors, same 10-unit margin | Requested fix for inset HUD | +| HUD positioning | Fullscreen upper-right anchors, fixed core and service-window geometry | Keep clear of top-edge overlays and stock scores/ammo without moving the core for services | | HUD rendering | Precached white shader, local max-health fallback, floating-point ratio, `getPlayers()` | T6 rendering/state handling without changing player max health | | Enemy count | Stock `_zm_utility::get_current_zombie_count()` plus queued zombies | Uses the map's zombie team and enemy filters | | Timers | Seed elapsed time for late joiners; stop round watcher/reset on game over | T6 timer behavior; keep frozen timers frozen | @@ -17,7 +17,7 @@ telemetry and leaderboard modules; neither includes bank or database modules. | Telemetry | Global `httpPost(url, options)`; T5-parity queue + heartbeat + game_start/end + watchdog + log-level dvar; per-player `guid` comes from the persistent `player.guid` field (T5 uses `player getGuid()` directly); per-match `kills`/`headshots`/`downs`/`revives` read from `player.pers[]`, with missing or negative values reported as `0` | Stock T6 connection initialization populates `.guid` before notifying `connected`; stock `initpersstat` / `incpersstat` maintain match counters in `player.pers[]`, whereas T5 uses `player.stats[]`. Payload keys match T5 for downstream shape parity | | Join | Port of T5 `tws_t5_join.gsc`; uses `level.spawnplayer` / `level.custom_spawnplayer` (lowercase) and `maps\mp\zombies\_zm::spectator_respawn`; readiness gated on `self.sessionstate == "spectator"` instead of T5's `self.initialized` | T6 stock spectator respawn hook lives in `_zm.gsc`; `self.initialized` is a T5 SP zombiemode marker that T6 stock does not populate the same way | -Common retains the T5 HUD constants, helpers and first-spawn dispatcher. +Common provides matching T5/T6 HUD layout constants, helpers and first-spawn dispatcher. HUD again owns the `hud_game_over` notification fan-out. Lock again waits on HUD notifications, applies T5's random password, and clears it on startup/game over. The added password-preservation dvars and independent lock watcher are gone. @@ -26,11 +26,30 @@ Common also listens for `.help` through the native `say` notification, independe of first-spawn initialization so newcomers can view commands while spectating. Each loaded command module registers its command and description with `register_help_command()` during `init()`. Lazy registry initialization preserves -registrations regardless of module initialization order. Help uses client HUD -elements below the health bar, sharing the HUD's anchors to avoid overlap with -timers, zombie count and health numbers. Text is created once per connection and -reused; each request restarts a 12-second visibility timer. Disconnect cleanup -is independent of that timer. The command handlers retain their existing listeners. +registrations regardless of module initialization order. Common owns one reusable +service window beneath the fixed core. Help uses four commands per page in stable +order, with 12 seconds per page and automatic dismissal after the final page. +`.help` / `.commands` restart at page one. Help pauses record page time, and a +`.leaders` request during help replaces the pending replay from page one. Service +state prevents old requests from closing newer displays. Disconnect and game-end +cleanup are independent of page timing. The command handlers retain their +existing listeners, and help/records work without the HUD module. + +Help reuses two multiline text columns and a muted command-column backdrop. +The service has 19 nonarchived elements independent of command/page count; +core + AFK use 15 + 1 archived elements. Hidden elements still occupy slots. +The [T6 player HUD structure](https://github.com/skiff/t6-gsc-helper/blob/50ff6cb7397c9376894a195f48118d1743812d1d/src/game/structs.hpp#L184) +has 31 slots in each bucket; it does not establish current Plutonium stock HUD +usage. Keep the live overlap/element-budget checks alongside the portable checks. + +The core is created on connection for waiting spectators with placeholders, while +timer updates retain the existing spawn/round lifecycle. Connection-time creation +must not seed the match clock. Current/maximum HP remains visible at full health; +the former co-op name row is removed and AFK uses a reserved footer. Opening a +service never changes core position or height. HUD `1.2.0`, leaderboard `1.1.0` +and AFK `1.0.1` require matching common `1.2.0+`; deploy the changed scripts +together and restart the map. This introduces no native-plugin/backend dependency +or full server-process restart requirement. AFK again listens to the native `say` notification, so it no longer needs plugin chat registration. The additional T6 controls are only held while AFK; `.back` @@ -49,7 +68,7 @@ The module is `tws_t6_leaderboard.gsc` in T6 and `tws_t5_leaderboard.gsc` in T5. Common's existing connection listener now also dispatches optional connection handlers after a one-frame yield. Leaderboard waits for player readiness and uses a level-owned -HTTP cache, independent of the first-playable-spawn handlers. T6 spectators keep +HTTP cache, independent of the first-spawn handlers. T6 spectators keep `hasSpawned` false, so T6 readiness uses session state and the intro-screen flag; T5 sets `hasSpawned` for both playable and spectator spawns. @@ -60,9 +79,10 @@ Mode/location combinations follow the and Electric Cherry's map catalogue. Unknown selections require an explicit API key instead of falling back to another variant sharing the engine map. -Validation for this change: 135 fixture/static checks pass using the actual -data-handling functions from both scripts with JavaScript engine-value shims. -All seven T6 scripts compile and assemble using a local Intel macOS driver built +Historical validation for the initial leaderboard change: 135 fixture/static +checks passed using the actual data-handling functions from both scripts with +JavaScript engine-value shims. All seven then-current T6 scripts compiled and +assembled using a local Intel macOS driver built from gsc-tool 1.5.1 sources. PowerShell was unavailable, so `check.ps1` itself was not executed here. T5 compilation and live plugin/gameplay checks were not run; gsc-tool has no T5 backend. The production API could not be reached from this @@ -137,15 +157,17 @@ wait for a normal round-start spawn, die, and verify `.join` is rejected. Also check a round-1 participant, reconnecting, disconnect during setup, and game over. Repeat on another T6 map; compile with the checker below first. -Local: `tools/check.ps1` compiles all seven files for T6 PC and checks shared -references and entry points. It requires PowerShell and gsc-tool. The `.help` -change passed the static entry-point/reference checks, but compilation and live -gameplay were not run. Compiler success does not certify runtime linking. +Local: `tools/check.ps1` compiles all eight files for T6 PC and checks shared +references and entry points. It requires PowerShell and gsc-tool. Run the +[shared HUD checks](../../README.md#hud-validation) for core/service behavior and +command regressions. Compiler success does not certify runtime linking. Live checks after deploying: -1. Join, late-join, die/respawn, and reconnect: one HUD per player, correct corner - position, health/Juggernog changes, zombies remaining, timers frozen at game over. +1. Join, late-join, die/respawn, and reconnect: one core per player, fixed + upper-right position, spectator placeholders without an early clock start, + visible full/damaged HP, health/Juggernog changes, zombies remaining, and + timers frozen at game over. Test safe-area settings and different resolutions. 2. `.afk` blocks movement, shooting and grenades; `.back` restores them. Try rapid toggles, a round transition, death/respawn and game over. 3. Lock fires at the threshold and clears on game over/restart. HUD must be installed. @@ -153,12 +175,21 @@ Live checks after deploying: 4. Telemetry sends the initial occupied round and each new round to the existing endpoint. Check server ID/player count, same-round joins and an unreachable API. 5. Repeat on the actual server maps, especially afterlife and special-enemy rounds. -6. Type `.help` and `.HELP` before first spawn, during play and while AFK: see - `.help`, `.afk`, `.back` and `.join` with descriptions, only on the requesting - player's screen below the health bar. Take damage so health numbers appear - and verify they do not overlap help. Repeat the command before 12 seconds - elapse: one list should remain visible until 12 seconds after the latest - request. Reopen after expiry and reconnect; verify no duplicates or leftovers. - Remove the AFK or Join module, restart the map and confirm its commands are - absent. Restore the modules and confirm `.afk`, `.back` and `.join` still - behave as before. +6. Type `.help`, `.HELP` and `.commands` before first spawn, during play and + while AFK: four commands per page, only on the requesting player's screen, + without moving or resizing the core. Verify 12 seconds per page and dismissal + after the final page; repeat on page two to restart page one. Remove optional + modules and restart the map to confirm their commands are absent. Restore + the modules and confirm their command behavior is unchanged. +7. Interrupt a record with help and check that its remaining page time resumes. + Repeat `.leaders` during help; only the latest replay should start from page + one after help closes. Check long names across pages, no records and + unavailable records. Reopen after expiry, disconnect/reconnect and end the + game during a service; verify no duplicates or leftovers. Repeat without HUD. + +The stock T6 [HUD initialization](https://raw.githubusercontent.com/plutoniummod/t6-scripts/main/ZM/Core/maps/mp/gametypes_zm/_hud.gsc) +sets a nominal font height of 12; the corresponding +[font helper](https://raw.githubusercontent.com/plutoniummod/t6-scripts/main/ZM/Core/maps/mp/gametypes_zm/_hud_util.gsc) +uses `int(level.fontheight * fontscale)` for its height. The portable HUD checks +use that nominal box to prevent holder-row and AFK-footer overlaps. Exact glyph +extents and multiline spacing still need live validation, especially on T5. diff --git a/t6/scripts/README.md b/t6/scripts/README.md index e8c71eb..f00ce5a 100644 --- a/t6/scripts/README.md +++ b/t6/scripts/README.md @@ -7,19 +7,20 @@ private server token. | File | Role | Dependencies | | --- | --- | --- | -| `tws_t6_common.gsc` | Shared HUD values, helpers, `.help`, connection and first-spawn dispatchers | Stock T6 | -| `tws_t6_hud.gsc` | Top-left Discord URL, match/round timers, name, zombie count and health bar | Common | -| `tws_t6_afk.gsc` | `.afk` / `.back`, protection, movement and weapon blocking | Common | +| `tws_t6_common.gsc` | Shared HUD values, helpers, paginated `.help`, service window, connection and first-spawn dispatchers | Stock T6 | +| `tws_t6_hud.gsc` | Fixed upper-right core: branding, timers, zombie count, current/maximum HP and health bar | Common 1.2.0+ | +| `tws_t6_afk.gsc` | `.afk` / `.back`, protection, movement and weapon blocking | Common 1.2.0+ | | `tws_t6_lock.gsc` | Lock at round 20; clear password on game over | Common, HUD | | `tws_t6_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils | | `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common | -| `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common, tws-t6-gsc-utils | +| `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common 1.2.0+, tws-t6-gsc-utils | | `tws_t6_bank.gsc` | Persistent shared GUID bank | Common, telemetry, TWS plugin 1.2.0 | ## Install 1. Copy all eight `tws_t6_*.gsc` files into - `Plutonium/storage/t6/scripts/zm/`. Update common along with leaderboard. + `Plutonium/storage/t6/scripts/zm/`. Update common along with HUD, leaderboard + and AFK when those modules are installed. 2. For telemetry, leaderboard or bank, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1, in `Plutonium/plugins/` and restart the server process. The other five scripts do not require that plugin. @@ -67,14 +68,24 @@ changing points. Stock T6 populates that field before the `connected` notification. Follow the shared [identity and deployment checks](../../README.md#bank-identity-validation). -## Persistent Discord text +## Core HUD and service window -The HUD shows `https://discord.tws.lol` in small muted-gray text -at the top-left, including for newcomers waiting in spectator mode. The label -is created once per connection and persists across death and respawn. The -gameplay HUD starts one line lower, keeping timers, help and records clear. -Install HUD `1.1.2` with common `1.1.0` or later, then restart the map; no plugin -update or full server-process restart is required. +The fixed upper-right core shows `TWS ZOMBIES`, `discord.tws.lol`, side-by-side +match/round timers, zombie count, current/maximum HP and a full-width health bar. +HP stays visible at full health. A reserved footer holds the AFK indicator; +there is no co-op name row. The core is created once per connection, including +for spectator newcomers with placeholders until values are available. Creation +does not seed the match clock. It persists across death and respawn. +Clocks, health and zombie-count displays freeze at game over. Health fallbacks +only affect the display and never modify gameplay max health. + +Common provides one fixed-height service window beneath the core for help or +records. Services never move or resize the core. They remain private and work +without the HUD module installed. See the [shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6). + +Install HUD `1.2.0`, leaderboard `1.1.0` and AFK `1.0.1` with common `1.2.0+`. +Update the installed scripts together and restart the map; this change requires +no new plugin/backend dependency or full server-process restart. ## Map record announcements @@ -99,8 +110,10 @@ apply to both versions. All T6 script filenames use the `tws_t6_` prefix. Type `.help` or `.commands` in chat to see the commands supplied by the loaded TWS modules and a short description of each. The list is shown only to the requesting player, including spectators who have not spawned. Commands are case-insensitive. -Help appears below the health bar for 12 seconds, clear of the timers, zombie -counter and health display. Typing either command again restarts that display time. +Help uses the service window below the core. It shows four commands per page, +advances automatically every 12 seconds and closes after the last page. The +panel retains its height on short pages. Typing either command again returns to +page one with a fresh timer. | Command | Description | | --- | --- | @@ -109,20 +122,25 @@ counter and health display. Typing either command again restarts that display ti | `.back` | Return from AFK | | `.join` | Join mid-round (newcomers only) | | `.leaders` | Show the map record again (leaderboard module required) | +| `.b` | Check bank balance (bank module required) | +| `.d ` | Deposit points (bank module required) | +| `.w ` | Withdraw points (bank module required) | AFK and Join register their commands during `init()` through `tws_t6_common::register_help_command(command, description)`. If a module is -omitted, its commands are omitted from `.help`. Register new commands alongside -their module's initialization so the list stays current. +omitted, its commands are omitted from `.help`. Commands follow the order above, +independent of initialization order. Register new commands alongside their +module's initialization so the list stays current. The enabled leaderboard module registers `.leaders`, which replays the latest cached map record privately. Repeating it restarts the record from the first page; -help temporarily hides the record until its own display ends. +help pauses the record's remaining page time and resumes it after the final +help page. `.leaders` during help replaces the pending record replay from page one. ## Defaults, matching T5 - HUD values live in `tws_t6_common::common_config()`. That function and `ensure_config()` came from T5; they initialize in-script values and do not - load a config file. The requested fullscreen corner alignment is retained. + load a config file. Core and service panels use fullscreen upper-right anchors. - AFK uses T5's native `level waittill("say", message, player)` listener. `.back` restores movement and weapons immediately, then ends protection after two seconds. The T6 control/weapon fix remains in place. @@ -211,6 +229,9 @@ another mod installed one first. ## Validation +Run the shared [portable and live HUD checks](../../README.md#hud-validation) +for core placement, service pagination, record interruptions and command regressions. + To compile all eight scripts for T6 PC with gsc-tool: ```powershell diff --git a/t6/scripts/tws_t6_afk.gsc b/t6/scripts/tws_t6_afk.gsc index 44a946d..d49a531 100644 --- a/t6/scripts/tws_t6_afk.gsc +++ b/t6/scripts/tws_t6_afk.gsc @@ -48,9 +48,9 @@ afk_spawn() self.hud_afk_on = false; self.tws_afk_protected = false; self.tws_afk_controls_locked = false; - elem = scripts\zm\tws_t6_common::make_text_elem(self, - level.hud_x + 42, level.hud_row_match, "left", - level.hud_font_small, level.hud_c_afk); + elem = scripts\zm\tws_t6_common::make_panel_text(self, + level.hud_pad, level.hud_row_afk, "left", + level.hud_font_afk, level.hud_c_afk); elem SetText("[AFK]"); elem.alpha = 0; self.hud_afk_elem = elem; diff --git a/t6/scripts/tws_t6_common.gsc b/t6/scripts/tws_t6_common.gsc index bf753a2..10fc669 100644 --- a/t6/scripts/tws_t6_common.gsc +++ b/t6/scripts/tws_t6_common.gsc @@ -13,6 +13,9 @@ init() common_config(); register_help_command(".help", "List available commands"); + level thread service_game_over_via("end_game"); + level thread service_game_over_via("game_ended"); + level thread service_game_over_via("intermission"); level thread help_chat_listener(); level thread on_player_connect_dispatch(); } @@ -22,41 +25,50 @@ common_config() if (isDefined(level.hud_x)) return; - // Fullscreen coordinates; reserve the top line for the Discord URL. + // Existing generic helpers retain their screen-anchor conventions. Panels + // use their own right-edge origin and never push stock HUD areas around. level.hud_x = 10; level.hud_y = 30; - - level.hud_row_match = level.hud_y; - level.hud_row_round = level.hud_y + 15; - level.hud_row_name = level.hud_y + 32; - level.hud_row_count = level.hud_y + 54; - level.hud_row_bar = level.hud_y + 84; - - level.hud_font_small = 1.4; - level.hud_font_large = 2.0; - level.hud_font_mid = 1.6; - - level.hud_bar_width = 120; - level.hud_bar_height = 8; - - level.hud_col_count = level.hud_x + 90; - level.hud_col_name = level.hud_x + 60; - level.hud_col_health = level.hud_x + level.hud_bar_width + 12; - - level.hud_rate = 0.1; - - // green/amber/red ramp is health-only - level.hud_c_label = (0.85, 0.85, 0.85); - level.hud_c_dim = (0.55, 0.55, 0.55); - level.hud_c_count = (1, 1, 1); - level.hud_c_name = (0.4, 0.9, 0.5); - level.hud_c_high = (0.2, 1, 0.4); - level.hud_c_mid = (1, 0.8, 0); - level.hud_c_low = (1, 0.2, 0.2); - level.hud_c_afk = (1, 0.8, 0.2); - - // numbers fade in below this fraction of max hp + // Retain the legacy fields for consumers of the original common config. + // The panel renderers below use dedicated row/column offsets instead. + level.hud_row_match = level.hud_y; + level.hud_row_round = level.hud_y + 15; + level.hud_row_name = level.hud_y + 32; + level.hud_row_count = level.hud_y + 54; + level.hud_col_count = level.hud_x + 90; + level.hud_col_name = level.hud_x + 60; + level.hud_col_health = level.hud_x + 120 + 12; level.hud_health_threshold = 1.0; + level.hud_panel_right = 10; + level.hud_panel_top = 42; + level.hud_panel_width = 220; + level.hud_core_height = 100; + level.hud_service_y = 108; + level.hud_service_height = 124; + level.hud_pad = 10; + level.hud_row_header = 10; + level.hud_row_timers = 34; + level.hud_row_stats = 59; + level.hud_row_bar = 80; + level.hud_row_afk = 86; + + level.hud_font_small = 1.1; + level.hud_font_large = 1.6; + level.hud_font_mid = 1.25; + level.hud_font_afk = 1.0; + level.hud_bar_width = 200; + level.hud_bar_height = 4; + level.hud_rate = 0.1; + level.hud_c_panel = (0.07, 0.10, 0.08); + level.hud_panel_alpha = 0.72; + level.hud_c_label = (0.72, 0.77, 0.74); + level.hud_c_dim = (0.65, 0.70, 0.67); + level.hud_c_count = (0.93, 0.96, 0.94); + level.hud_c_name = (0.65, 0.85, 0.70); + level.hud_c_high = (0.65, 0.85, 0.70); + level.hud_c_mid = (1, 0.8, 0); + level.hud_c_low = (1, 0.2, 0.2); + level.hud_c_afk = (1, 0.8, 0.2); if (!isDefined(level.tws_spawn_handlers)) level.tws_spawn_handlers = []; @@ -112,58 +124,356 @@ help_chat_listener() } } +// A single per-player controller owns service priority, pages and expiry. show_help() { - // Cache the text per connection; repeated SetText calls consume config strings. - if (!isDefined(self.tws_help_elems)) - { - elems = []; - // Use the same anchors as the HUD, with room below the health numbers. - y = level.hud_row_bar + level.hud_bar_height + 20; - title = make_text_elem(self, level.hud_x, y, - "left", level.hud_font_mid, level.hud_c_afk); - title SetText("Commands"); - elems[elems.size] = title; + if (isDefined(level.tws_service_ended)) + return; + self service_ensure(); + state = self.tws_service; + service_advance(state, getTime()); + state.generation++; + state.help = service_request("help", service_page_count(state.commands.size, 4), state.generation); + service_render(state); +} +// An undefined snapshot is the explicit unavailable view for manual requests. +show_record(snapshot) +{ + if (isDefined(level.tws_service_ended)) + return; + self service_ensure(); + state = self.tws_service; + service_advance(state, getTime()); + count = 0; + if (isDefined(snapshot)) + count = snapshot.lines.size; + state.generation++; + state.record = service_request("record", service_page_count(count, 3), state.generation); + state.record.snapshot = snapshot; + service_render(state); +} + +service_page_count(count, rows) +{ + return int((max(count, 1) + rows - 1) / rows); +} + +service_request(kind, pages, generation) +{ + request = spawnStruct(); + request.kind = kind; + request.generation = generation; + request.page = 0; + request.pages = pages; + request.remaining = 12000; + return request; +} + +service_current(state) +{ + if (isDefined(state.help)) + return state.help; + return state.record; +} + +service_advance(state, now) +{ + elapsed = max(0, now - state.updated_at); + state.updated_at = now; + request = service_current(state); + if (!isDefined(request) || state.closed) + return; + + // Only the visible request consumes time. Help leaves the record untouched. + request.remaining -= elapsed; + if (request.remaining > 0) + return; + request.page++; + request.remaining = 12000; + if (request.page >= request.pages) + { + if (request.kind == "help") + state.help = undefined; + else + state.record = undefined; + } +} + +// Stable order is independent of module initialization order; absent commands +// stay absent. Unknown future commands follow the built-in ones in registry order. +help_rank(command) +{ + if (command == ".help") return 0; + if (command == ".afk") return 1; + if (command == ".back") return 2; + if (command == ".join") return 3; + if (command == ".leaders") return 4; + if (command == ".b") return 5; + if (command.size >= 3 && getSubStr(command, 0, 3) == ".d ") return 6; + if (command.size >= 3 && getSubStr(command, 0, 3) == ".w ") return 7; + return 8; +} + +help_entries() +{ + entries = []; + if (!isDefined(level.tws_help_commands)) + return entries; + for (rank = 0; rank <= 8; rank++) + { for (i = 0; i < level.tws_help_commands.size; i++) { entry = level.tws_help_commands[i]; - elem = make_text_elem(self, level.hud_x, y + 22 + i * 18, - "left", level.hud_font_small, level.hud_c_count); - elem SetText("^3" + entry.command + "^7 - " + entry.description); - elems[elems.size] = elem; + if (help_rank(entry.command) == rank) + entries[entries.size] = entry; } - - self.tws_help_elems = elems; - self thread destroy_on_disconnect(elems); } - - for (i = 0; i < self.tws_help_elems.size; i++) - { - elem = self.tws_help_elems[i]; - elem.alpha = 1; - } - - // Only the expiry timer is replaced; the say listener and cleanup stay alive. - self notify("tws_help_refresh"); - self thread hide_help_after_delay(); + return entries; } -hide_help_after_delay() +help_command_label(command) +{ + rank = help_rank(command); + if (rank == 6) return ".d "; + if (rank == 7) return ".w "; + return command; +} + +help_description(entry) +{ + rank = help_rank(entry.command); + if (rank == 0) return "Show commands"; + if (rank == 1) return "Enable protection"; + if (rank == 2) return "Return to play"; + if (rank == 3) return "Join as newcomer"; + if (rank == 4) return "View records"; + if (rank == 5) return "Check balance"; + if (rank == 6) return "Deposit points"; + if (rank == 7) return "Withdraw points"; + return entry.description; +} + +service_ensure() +{ + if (isDefined(self.tws_service)) + return; + ensure_config(); + state = spawnStruct(); + state.elems = []; + state.commands = help_entries(); + state.help = undefined; + state.record = undefined; + state.closed = false; + state.updated_at = getTime(); + state.rendered = false; + state.generation = 0; + state.last_generation = 0; + state.last_page = -1; + state.record_text = []; + state.help_page = -1; + state.rows = []; + self.tws_service = state; + + y = level.hud_service_y; + state.background = make_panel_shader(self, 0, y, level.hud_panel_width, + level.hud_service_height, level.hud_c_panel, level.hud_panel_alpha, 0); + state.elems[state.elems.size] = state.background; + state.help_title = service_text(self, state, 10, 10, "left", 1.1, "COMMANDS", level.hud_c_label); + state.record_title = service_text(self, state, 10, 10, "left", 1.1, "MAP RECORD", level.hud_c_label); + state.help_footer = service_text(self, state, 10, 108, "left", 0.9, + "Four commands per page", level.hud_c_dim); + state.record_footer = service_text(self, state, 10, 108, "left", 0.9, + "Briefly shown - .leaders to view again", level.hud_c_dim); + state.page_number = service_text(self, state, 178, 10, "right", 1.1, "", level.hud_c_dim); + state.page_slash = service_text(self, state, 184, 10, "center", 1.1, "/", level.hud_c_dim); + state.page_total = service_text(self, state, 210, 10, "right", 1.1, "", level.hud_c_dim); + // Two fixed text columns keep the HUD element budget independent of the + // command count. Strings change only at page boundaries, never per tick. + state.command_back = make_panel_shader(self, 10, y + 30, 65, 68, + (0.16, 0.19, 0.17), 0.9, 5); + state.elems[state.elems.size] = state.command_back; + state.command_column = service_text(self, state, 14, 31, + "left", 1.0, "", level.hud_c_name); + state.description_column = service_text(self, state, 81, 31, + "left", 1.0, "", level.hud_c_count); + state.round_label = service_text(self, state, 10, 32, "left", 1.1, "ROUND", level.hud_c_label); + state.round_number = service_text(self, state, 54, 27, "left", 1.9, "", level.hud_c_count); + state.holders_label = service_text(self, state, 10, 52, "left", 1.0, "RECORD HOLDERS", level.hud_c_label); + for (i = 0; i < 3; i++) + state.rows[i] = service_text(self, state, 10, 65 + i * 13, + "left", 1.1, "", level.hud_c_count); + state.empty = service_text(self, state, 10, 40, "left", 1.1, + "No recorded high round yet", level.hud_c_count); + state.unavailable = service_text(self, state, 10, 40, "left", 1.1, + "Map record unavailable", level.hud_c_count); + for (i = 0; i < state.elems.size; i++) + { + state.elems[i].alpha = 0; + // Keep temporary private services out of the archived HUD bucket. + // Hidden elements still occupy slots; do not allocate one per page. + state.elems[i].archived = false; + } + + self thread service_loop(state); + self thread service_disconnect(state); + self thread service_end_game(state); +} + +service_text(player, state, x, y, align, scale, text, colour) +{ + elem = make_panel_text(player, x, level.hud_service_y + y, align, scale, colour); + elem.alpha = 0; + if (text != "") + elem SetText(text); + state.elems[state.elems.size] = elem; + return elem; +} + +service_render(state) +{ + if (state.closed) + return; + request = service_current(state); + page = -1; + generation = 0; + if (isDefined(request)) + { + page = request.page; + generation = request.generation; + } + if (state.rendered && state.last_generation == generation && state.last_page == page) + return; + state.rendered = true; + state.last_generation = generation; + state.last_page = page; + for (i = 0; i < state.elems.size; i++) + state.elems[i].alpha = 0; + if (!isDefined(request)) + return; + + state.background.alpha = level.hud_panel_alpha; + state.page_number SetValue(page + 1); + state.page_total SetValue(request.pages); + state.page_number.alpha = 1; + state.page_slash.alpha = 1; + state.page_total.alpha = 1; + if (request.kind == "help") + { + state.help_title.alpha = 1; + state.help_footer.alpha = 1; + if (state.help_page != page) + { + command_text = ""; + description_text = ""; + for (i = 0; i < 4; i++) + { + index = page * 4 + i; + if (index < state.commands.size) + { + if (i > 0) + { + command_text += "\n"; + description_text += "\n"; + } + entry = state.commands[index]; + command_text += help_command_label(entry.command); + description_text += help_description(entry); + } + } + state.command_column SetText(command_text); + state.description_column SetText(description_text); + state.help_page = page; + } + state.command_back.alpha = 0.9; + state.command_column.alpha = 1; + state.description_column.alpha = 1; + return; + } + + state.record_title.alpha = 1; + state.record_footer.alpha = 1; + snapshot = request.snapshot; + if (!isDefined(snapshot)) + { + state.unavailable.alpha = 1; + return; + } + if (snapshot.round == 0) + { + state.empty.alpha = 1; + return; + } + state.round_label.alpha = 1; + state.round_number SetValue(snapshot.round); + state.round_number.alpha = 1; + state.holders_label.alpha = 1; + for (i = 0; i < 3; i++) + { + index = page * 3 + i; + if (index < snapshot.lines.size) + { + text = snapshot.lines[index]; + // Skip identical text when replaying a page or resuming after help. + if (!isDefined(state.record_text[i]) || state.record_text[i] != text) + { + state.rows[i] SetText(text); + state.record_text[i] = text; + } + state.rows[i].alpha = 1; + } + } +} + +service_loop(state) { self endon("disconnect"); - self endon("tws_help_refresh"); - - wait 12; - - for (i = 0; i < self.tws_help_elems.size; i++) + level endon("tws_service_game_over"); + for (;;) { - elem = self.tws_help_elems[i]; - elem.alpha = 0; + service_advance(state, getTime()); + service_render(state); + wait 0.1; } } -// Shared dispatcher: one HUD instance and account load per connection. +service_disconnect(state) +{ + self waittill("disconnect"); + service_destroy(state); +} + +service_end_game(state) +{ + self endon("disconnect"); + level waittill("tws_service_game_over"); + service_destroy(state); +} + +service_destroy(state) +{ + if (state.closed) + return; + state.closed = true; + state.help = undefined; + state.record = undefined; + for (i = 0; i < state.elems.size; i++) + { + if (isDefined(state.elems[i])) + state.elems[i] Destroy(); + } + state.elems = []; +} + +service_game_over_via(event) +{ + level endon("tws_service_game_over"); + level waittill(event); + level.tws_service_ended = true; + level notify("tws_service_game_over"); +} + +// The only connected listener; modules use the shared registries. on_player_connect_dispatch() { for (;;) @@ -240,6 +550,32 @@ make_shader_elem(player, x, y) return elem; } +// Panel x/y are local to the fixed upper-right core origin. A right screen +// anchor keeps the panel on-screen across aspect ratios; negative offsets grow +// left from that edge. Keep each engine's existing vertical anchor convention. +make_panel_text(player, x, y, align_x, scale, colour) +{ + elem = make_text_elem(player, x - level.hud_panel_width - level.hud_panel_right, + level.hud_panel_top + y, align_x, scale, colour); + elem.horzAlign = "right"; + elem.sort = 20; + elem.archived = true; + return elem; +} + +make_panel_shader(player, x, y, width, height, colour, alpha, sort) +{ + elem = make_shader_elem(player, x - level.hud_panel_width - level.hud_panel_right, + level.hud_panel_top + y); + elem.horzAlign = "right"; + elem.color = colour; + elem.alpha = alpha; + elem.sort = sort; + elem.archived = true; + elem SetShader("white", width, height); + return elem; +} + destroy_on_disconnect(elems) { self waittill("disconnect"); diff --git a/t6/scripts/tws_t6_hud.gsc b/t6/scripts/tws_t6_hud.gsc index 8936291..82574cd 100644 --- a/t6/scripts/tws_t6_hud.gsc +++ b/t6/scripts/tws_t6_hud.gsc @@ -1,4 +1,4 @@ -/* tws_t6_hud.gsc - top-left Discord URL, timers, zombie count and health bar. */ +/* tws_t6_hud.gsc - fixed upper-right core HUD, visible from connection. */ #include maps\mp\_utility; #include common_scripts\utility; @@ -15,36 +15,90 @@ init() level thread round_watcher(); level thread game_over_watcher(); + scripts\zm\tws_t6_common::register_connect_handler(::hud_connect); scripts\zm\tws_t6_common::register_spawn_handler(::hud_start); - scripts\zm\tws_t6_common::register_connect_handler(::hud_discord); } -// One static label per connection, including newcomers waiting to spawn. -hud_discord() +// Connection construction never seeds clocks or reads player gameplay state. +// The first-spawn fallback also handles a spawn before the connect callback. +hud_connect() { self endon("disconnect"); - label = scripts\zm\tws_t6_common::make_text_elem( - self, 10, 10, "left", 1.2, (0.65, 0.65, 0.65)); - label SetText("https://discord.tws.lol"); + if (isDefined(self.tws_hud_elems)) + return; - elems = []; - elems[elems.size] = label; - self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems); + self.tws_hud_elems = []; + background = scripts\zm\tws_t6_common::make_panel_shader( + self, 0, 0, level.hud_panel_width, level.hud_core_height, + level.hud_c_panel, level.hud_panel_alpha, 0); + self.tws_hud_elems[self.tws_hud_elems.size] = background; + + self hud_text(level.hud_pad, level.hud_row_header, "left", + level.hud_font_small, level.hud_c_name, "TWS ZOMBIES"); + self hud_text(level.hud_panel_width - level.hud_pad, level.hud_row_header, + "right", level.hud_font_small, level.hud_c_dim, "discord.tws.lol"); + + self hud_text(level.hud_pad, level.hud_row_timers, "left", + level.hud_font_small, level.hud_c_label, "MATCH"); + self.tws_hud_match = self hud_text(104, level.hud_row_timers - 2, "right", + level.hud_font_mid, level.hud_c_count, "--:--"); + self hud_text(114, level.hud_row_timers, "left", + level.hud_font_small, level.hud_c_label, "ROUND"); + self.tws_hud_round = self hud_text(level.hud_panel_width - level.hud_pad, + level.hud_row_timers - 2, "right", + level.hud_font_mid, level.hud_c_count, "--:--"); + + self hud_text(level.hud_pad, level.hud_row_stats, "left", + level.hud_font_small, level.hud_c_label, "ZOMBIES"); + self.tws_hud_count = self hud_text(67, level.hud_row_stats, "left", + level.hud_font_mid, level.hud_c_count, "--"); + self hud_text(132, level.hud_row_stats, "left", + level.hud_font_small, level.hud_c_label, "HP"); + + // Independent numeric elements avoid a new config string on every tick. + self.tws_hud_health = self hud_text(174, level.hud_row_stats, "right", + level.hud_font_mid, level.hud_c_count, "--"); + self.tws_hud_slash = self hud_text(181, level.hud_row_stats, "center", + level.hud_font_mid, level.hud_c_count, "/"); + self.tws_hud_maxhealth = self hud_text(level.hud_panel_width - level.hud_pad, + level.hud_row_stats, "right", level.hud_font_mid, level.hud_c_count, "--"); + + track = scripts\zm\tws_t6_common::make_panel_shader( + self, level.hud_pad, level.hud_row_bar, + level.hud_bar_width, level.hud_bar_height, (0.18, 0.22, 0.19), 0.8, 10); + self.tws_hud_elems[self.tws_hud_elems.size] = track; + self.tws_hud_bar = scripts\zm\tws_t6_common::make_panel_shader( + self, level.hud_pad, level.hud_row_bar, + level.hud_bar_width, level.hud_bar_height, level.hud_c_high, 0, 11); + self.tws_hud_elems[self.tws_hud_elems.size] = self.tws_hud_bar; + + self thread scripts\zm\tws_t6_common::destroy_on_disconnect(self.tws_hud_elems); +} + +hud_text(x, y, align, scale, colour, text) +{ + elem = scripts\zm\tws_t6_common::make_panel_text(self, x, y, align, scale, colour); + elem SetText(text); + self.tws_hud_elems[self.tws_hud_elems.size] = elem; + return elem; } hud_start() { self endon("disconnect"); - if (isDefined(level.tws_ended) && level.tws_ended) + + self hud_connect(); + if (isDefined(self.tws_hud_bound) || is_true(level.tws_ended)) return; + self.tws_hud_bound = true; self thread hud_timer_row(); self thread hud_zombie_counter(); self thread hud_health_display(); } -// Same game-over notification fan-out as T5. +// Race the map-dependent game-over notifications once at level scope. game_over_watcher() { level thread game_over_via("end_game"); @@ -60,7 +114,7 @@ game_over_via(notify_name) level notify("hud_game_over"); } -// Poll stock round state; the first player spawn seeds pre-set round 1. +// Pre-set round 1 is seeded on first spawn; only observed transitions seed here. round_watcher() { level endon("hud_game_over"); @@ -93,7 +147,7 @@ hud_timer_row() { self endon("disconnect"); - // fallback seed for the round-1-already-active case (round_watcher misses it) + // A visible panel for a waiting newcomer must not start a pre-set round 1. if (!isDefined(level.match_start_time) && isDefined(level.round_number) && level.round_number >= 1) @@ -103,58 +157,14 @@ hud_timer_row() level notify("hud_match_started"); } - match = scripts\zm\tws_t6_common::make_text_elem( - self, level.hud_x, level.hud_row_match, - "left", level.hud_font_small, level.hud_c_dim); - match.alpha = 0; // revealed by match_timer_bind when round 1 opens - - round = scripts\zm\tws_t6_common::make_text_elem( - self, level.hud_x, level.hud_row_round, - "left", level.hud_font_small, level.hud_c_dim); + round = self.tws_hud_round; round SetTimerUp(0); if (isDefined(level.round_start_time)) round SetTimerUp(max(0, (getTime() - level.round_start_time) / 1000.0)); - // name only shows in co-op — your own name is noise in solo - name = scripts\zm\tws_t6_common::make_text_elem( - self, level.hud_x, level.hud_row_name, - "left", level.hud_font_small, level.hud_c_name); - - if (isDefined(self.name)) - name SetText(self.name); - else - name SetText(self.playername); - - name.alpha = 0; - - elems = []; - elems[elems.size] = match; - elems[elems.size] = round; - elems[elems.size] = name; - self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems); - self thread reset_timer_each_round(round); - self thread match_timer_bind(match); - self thread freeze_timers_on_game_over(match, round); - - coop = -1; - - for (;;) - { - is_coop = 0; - - players = getPlayers(); - if (players.size > 1) - is_coop = 1; - - if (is_coop != coop) - { - name.alpha = is_coop; - coop = is_coop; - } - - wait (1); - } + self thread match_timer_bind(self.tws_hud_match); + self thread freeze_timers_on_game_over(self.tws_hud_match, round); } reset_timer_each_round(timer) @@ -171,26 +181,20 @@ reset_timer_each_round(timer) } } -// T6 timer seed includes elapsed time for late joiners. +// T6 accepts elapsed seconds, including time before a late joiner's spawn. match_timer_bind(elem) { self endon("disconnect"); + level endon("hud_game_over"); if (!isDefined(level.match_start_time)) level waittill("hud_match_started"); if (isDefined(elem)) - { elem SetTimerUp(max(0, (getTime() - level.match_start_time) / 1000.0)); - elem.alpha = 1; - } } -/* - Level-scope trigger only — per-player death/health polling is intercepted - by the zombiemode scripts. Freeze value math relies on the timers being - anchored at first spawn / first round transition, not level init. -*/ +// The panel remains visible at game over, with authoritative frozen clocks. freeze_timers_on_game_over(match_elem, round_elem) { self endon("disconnect"); @@ -206,22 +210,9 @@ freeze_timers_on_game_over(match_elem, round_elem) hud_zombie_counter() { self endon("disconnect"); + level endon("hud_game_over"); - label = scripts\zm\tws_t6_common::make_text_elem( - self, level.hud_x, level.hud_row_count, - "left", level.hud_font_large, level.hud_c_label); - label SetText("Zombies"); - - count = scripts\zm\tws_t6_common::make_text_elem( - self, level.hud_col_count, level.hud_row_count, - "left", level.hud_font_large, level.hud_c_count); - count SetValue(0); - - elems = []; - elems[elems.size] = label; - elems[elems.size] = count; - self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems); - + count = self.tws_hud_count; last = -1; for (;;) @@ -245,108 +236,58 @@ hud_zombie_counter() hud_health_display() { self endon("disconnect"); + level endon("hud_game_over"); bar_width = level.hud_bar_width; - - // -2 on both axes so the back frames the bar with 2px padding - back = scripts\zm\tws_t6_common::make_shader_elem(self, level.hud_x - 2, level.hud_row_bar - 2); - back.alpha = 0.5; - back.color = (0, 0, 0); - back SetShader("white", bar_width + 4, level.hud_bar_height + 4); - - bar = scripts\zm\tws_t6_common::make_shader_elem(self, level.hud_x, level.hud_row_bar); - bar.color = level.hud_c_high; - bar SetShader("white", bar_width, level.hud_bar_height); - - /* - "87/160" is three elements, not a rebuilt string. SetText allocates - a config string per call; per-tick rebuilds crash the server. - cur is right-aligned so digits grow leftward and the slash + max - never shift. - */ - cur = scripts\zm\tws_t6_common::make_text_elem( - self, level.hud_col_health + 26, level.hud_row_bar + 3, - "right", level.hud_font_mid, level.hud_c_high); - cur SetValue(self.health); - - slash = scripts\zm\tws_t6_common::make_text_elem( - self, level.hud_col_health + 30, level.hud_row_bar + 3, - "center", level.hud_font_mid, level.hud_c_high); - slash SetText("/"); - - max_hp = scripts\zm\tws_t6_common::make_text_elem( - self, level.hud_col_health + 34, level.hud_row_bar + 3, - "left", level.hud_font_mid, level.hud_c_high); - max_hp SetValue(100); - - cur.alpha = 0; - slash.alpha = 0; - max_hp.alpha = 0; - - elems = []; - elems[elems.size] = back; - elems[elems.size] = bar; - elems[elems.size] = cur; - elems[elems.size] = slash; - elems[elems.size] = max_hp; - self thread scripts\zm\tws_t6_common::destroy_on_disconnect(elems); - - last_width = bar_width; + bar = self.tws_hud_bar; + cur = self.tws_hud_health; + slash = self.tws_hud_slash; + max_hp = self.tws_hud_maxhealth; + last_width = -1; last_health = -1; - last_max = -1; - last_shown = -1; + last_max = -1; for (;;) { + // Display fallbacks must not alter gameplay health values. maximum = 100; if (isDefined(self.maxhealth) && self.maxhealth > 0) maximum = self.maxhealth; - frac = self.health / (maximum * 1.0); + current = 0; + if (isDefined(self.health)) + current = max(0, self.health); + frac = current / (maximum * 1.0); if (frac > 1) frac = 1; - if (frac < 0) - frac = 0; - width = int(max(frac * bar_width, 1)); - if (width != last_width) { bar SetShader("white", width, level.hud_bar_height); last_width = width; } - colour = level.hud_c_high; + bar.alpha = 1; + if (frac == 0) + bar.alpha = 0; + colour = level.hud_c_high; if (frac <= 0.25) colour = level.hud_c_low; else if (frac <= 0.5) colour = level.hud_c_mid; - bar.color = colour; - cur.color = colour; - slash.color = colour; + bar.color = colour; + cur.color = colour; + slash.color = colour; max_hp.color = colour; - // numbers only show once you're hurt - show = 0; - - if (frac < level.hud_health_threshold) - show = 1; - - if (show != last_shown) + // Health numbers remain visible at full health. + if (current != last_health) { - cur.alpha = show; - slash.alpha = show; - max_hp.alpha = show; - last_shown = show; - } - - if (self.health != last_health) - { - cur SetValue(self.health); - last_health = self.health; + cur SetValue(current); + last_health = current; } if (maximum != last_max) diff --git a/t6/scripts/tws_t6_leaderboard.gsc b/t6/scripts/tws_t6_leaderboard.gsc index 0f8a0c1..2f0f202 100644 --- a/t6/scripts/tws_t6_leaderboard.gsc +++ b/t6/scripts/tws_t6_leaderboard.gsc @@ -127,120 +127,17 @@ lb_request(manual) level endon("game_ended"); level endon("intermission"); - snapshot = lb_cached_record(); - if (!isDefined(snapshot)) - { - if (manual) - self iPrintLnBold("^3Map record unavailable; try .leaders again shortly"); - return; - } // A manual replay replaces any welcome announcement still waiting to run. + // This also applies when the cache is unavailable. if (manual) self notify("tws_lb_requested"); - self lb_show_hud(snapshot); -} + snapshot = lb_cached_record(); + if (!isDefined(snapshot) && !manual) + return; -lb_show_hud(snapshot) -{ - // Stop the previous display and its cleanup threads before replacing it. - self notify("tws_lb_hud_done"); - if (isDefined(self.tws_lb_elems)) - lb_hud_destroy(self.tws_lb_elems); - self endon("disconnect"); - self endon("tws_lb_hud_done"); - - scripts\zm\tws_t6_common::ensure_config(); - // Match .help's position, typography and row spacing below the health bar. - y = level.hud_row_bar + level.hud_bar_height + 20; - elems = []; - title = scripts\zm\tws_t6_common::make_text_elem( - self, level.hud_x, y, "left", level.hud_font_mid, level.hud_c_afk); - title.alpha = 0; - if (snapshot.round == 0) - title SetText("No recorded high round yet"); - else - title SetText("Record: Round " + snapshot.round); - elems[elems.size] = title; - - for (i = 0; i < 3; i++) - { - row = scripts\zm\tws_t6_common::make_text_elem( - self, level.hud_x, y + 22 + i * 18, - "left", level.hud_font_small, level.hud_c_count); - row.alpha = 0; - elems[elems.size] = row; - } - - // Cleanup runs independently of the display and ends when it is replaced. - self.tws_lb_elems = elems; - self thread lb_hud_disconnect(elems); - self thread lb_hud_end_game(elems, "end_game"); - self thread lb_hud_end_game(elems, "game_ended"); - self thread lb_hud_end_game(elems, "intermission"); - - offset = 0; - for (;;) - { - // Text changes only once per page, never during the visibility loop. - for (i = 0; i < 3; i++) - { - row = elems[i + 1]; - if (offset + i < snapshot.lines.size) - row SetText("^3Held by: ^7" + snapshot.lines[offset + i]); - else - row SetText(""); - } - - remaining = 12; - while (remaining > 0) - { - visible = !self lb_hud_help_visible(); - for (i = 0; i < elems.size; i++) - elems[i].alpha = visible; - wait 0.1; - // Pause the page while .help occupies the same area. - if (visible) - remaining -= 0.1; - } - offset += 3; - if (offset >= snapshot.lines.size) - break; - } - lb_hud_destroy(elems); - self.tws_lb_elems = undefined; - self notify("tws_lb_hud_done"); -} - -lb_hud_help_visible() -{ - return isDefined(self.tws_help_elems) && self.tws_help_elems.size > 0 && - isDefined(self.tws_help_elems[0]) && self.tws_help_elems[0].alpha > 0; -} - -lb_hud_disconnect(elems) -{ - self endon("tws_lb_hud_done"); - self waittill("disconnect"); - lb_hud_destroy(elems); -} - -lb_hud_end_game(elems, event) -{ - self endon("disconnect"); - self endon("tws_lb_hud_done"); - level waittill(event); - lb_hud_destroy(elems); - self.tws_lb_elems = undefined; - self notify("tws_lb_hud_done"); -} - -lb_hud_destroy(elems) -{ - for (i = 0; i < elems.size; i++) - { - if (isDefined(elems[i])) - elems[i] Destroy(); - } + // Common owns pagination, help priority and the single service window. + // Undefined snapshots produce a private unavailable page for manual replay. + self scripts\zm\tws_t6_common::show_record(snapshot); } lb_refresh() @@ -529,22 +426,85 @@ lb_holder_lines(names) { lines = []; line = ""; + columns = 0; for (i = 0; i < names.size; i++) { - if (line != "" && line.size + names[i].size + 2 > 100) + name = lb_holder_name(names[i]); + width = lb_holder_width(name); + if (line != "" && columns + width + 2 > 16) { lines[lines.size] = line; line = ""; + columns = 0; } if (line != "") + { line += ", "; - line += names[i]; + columns += 2; + } + line += name; + columns += width; } if (line != "") lines[lines.size] = line; return lines; } +lb_holder_name(value) +{ + // The service has 200 virtual units at font scale 1.1. Use a conservative + // 16-column bound, counting non-ASCII glyphs twice, pending in-game metrics. + // A long name gets one bounded entry; no tied holder is dropped. + value = lb_display_text(value, 64); + if (value == "") + value = "Unknown player"; + if (lb_holder_width(value) <= 16) + return value; + + output = ""; + columns = 0; + for (i = 0; i < value.size; i++) + { + code = level.tws_lb_bytes[getSubStr(value, i, i + 1)]; + width = 1; + if (code >= 128) + width = 2; + if (columns + width > 13) + break; + count = lb_glyph_bytes(code); + output += getSubStr(value, i, i + count); + columns += width; + i += count - 1; + } + return output + "..."; +} + +lb_holder_width(value) +{ + columns = 0; + for (i = 0; i < value.size; i++) + { + code = level.tws_lb_bytes[getSubStr(value, i, i + 1)]; + if (code >= 128) + columns += 2; + else + columns++; + i += lb_glyph_bytes(code) - 1; + } + return columns; +} + +lb_glyph_bytes(code) +{ + if (code >= 240) + return 4; + if (code >= 224) + return 3; + if (code >= 192) + return 2; + return 1; +} + lb_display_text(value, limit) { output = ""; diff --git a/tools/check_core_hud.mjs b/tools/check_core_hud.mjs new file mode 100644 index 0000000..473fa5e --- /dev/null +++ b/tools/check_core_hud.mjs @@ -0,0 +1,95 @@ +// Execute selected core HUD functions with engine-value shims. +// These checks are not a GSC VM, compiler, linker, or live layout test. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +const root = new URL('../', import.meta.url); +let checks = 0; +function check(name, fn) { fn(); checks++; console.log(name); } +function body(source, name) { + const match = new RegExp(`^${name}\\(([^\\n]*)\\)\\s*\\n\\{`, 'm').exec(source); + assert(match, name); + let at = match.index + match[0].length, end = at, depth = 1; + while (depth) { const ch = source[end++]; if (ch === '{') depth++; if (ch === '}') depth--; } + return [match[1], source.slice(at, end - 1)]; +} +for (const game of ['t5', 't6']) { + const source = readFileSync(new URL(`${game}/scripts/tws_${game}_hud.gsc`, root), 'utf8'); + const afk = readFileSync(new URL(`${game}/scripts/tws_${game}_afk.gsc`, root), 'utf8'); + check(`${game}: shared lifecycle, frozen clocks and engine-specific timer seeds`, () => { + assert(source.includes('register_connect_handler(::hud_connect)')); + assert(source.includes('register_spawn_handler(::hud_start)')); + assert(!source.includes('waittill("connected"')); + assert(!body(source, 'hud_connect')[1].includes('getTime()')); + for (const name of ['round_watcher', 'reset_timer_each_round', 'match_timer_bind']) + assert(body(source, name)[1].includes('level endon("hud_game_over")')); + const bind = body(source, 'match_timer_bind')[1]; + if (game === 't5') assert(bind.includes('elem SetTimerUp(0)')); + else assert(bind.includes('(getTime() - level.match_start_time) / 1000.0')); + assert(afk.includes('make_panel_text')); + assert(afk.includes('level.hud_row_afk')); + }); + const queued = [], elems = []; + const common = { + make_panel_text: (...args) => element(args), + make_panel_shader: (...args) => element(args), + }; + function element(args) { + const result = { args, alpha: 1, writes: [], SetText(value) { this.text = value; this.writes.push(['text', value]); }, + SetValue(value) { this.value = value; this.writes.push(['value', value]); }, + SetShader(...args) { this.shader = args; }, SetTimerUp(value) { this.timer = value; } }; + elems.push(result); return result; + } + const level = { round_number: 1, hud_panel_width: 220, hud_core_height: 100, hud_pad: 10, + hud_row_header: 10, hud_row_timers: 34, hud_row_stats: 59, hud_row_bar: 80, + hud_bar_width: 200, hud_bar_height: 4, hud_rate: .1, + hud_font_small: 1.1, hud_font_mid: 1.25, + hud_c_high: 'green', hud_c_mid: 'yellow', hud_c_low: 'red', notify() {} }; + const self = {}; + const context = vm.createContext({ self, level, common, queued, getTime: () => 123456, + isDefined: value => value !== undefined && value !== null, is_true: value => !!value, + int: Math.trunc, max: Math.max }); + for (const name of ['hud_connect', 'hud_text', 'hud_start', 'hud_timer_row', 'hud_health_display']) { + const [args, original] = body(source, name); + let code = original.replace(/\/\/[^\n]*/g, ''); + const locals = [...new Set([...code.matchAll(/(? match[1]))]; + code = code.replace(/(?:self|level) endon\([^;]+;/g, '') + .replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::/g, 'common.') + .replace(/self thread ([\w.]+)\(([^;]*)\);/g, (_, name, args) => `queued.push(['${name}', ${args}]);`) + .replace(/self (hud_\w+)\(/g, '$1(') + .replace(/(\w+) (SetText|SetValue|SetTimerUp|SetShader)\(([^;]*)\);/g, '$1.$2($3);') + .replace(/level notify\(([^;]*)\);/g, 'level.notify($1);') + .replace(/\.size\b/g, '.length') + .replace(/\((\d+\.\d+), (\d+\.\d+), (\d+\.\d+)\)/g, '[$1, $2, $3]') + .replace(/wait \(([^;]*)\);/g, 'yield $1;'); + vm.runInContext(`function${name === 'hud_health_display' ? '*' : ''} ${name}(${args}) { var ${locals.join(', ') || 'unused'}; ${code} }`, context); + } + check(`${game}: connection constructs one fixed panel without seeding clocks`, () => { + context.hud_connect(); + assert.equal(level.match_start_time, undefined); assert.equal(level.round_start_time, undefined); + assert.equal(self.tws_hud_match.text, '--:--'); assert.equal(self.tws_hud_health.text, '--'); + const before = elems.length; context.hud_connect(); assert.equal(elems.length, before); + assert.equal(queued.length, 1); assert.equal(queued[0][0], 'common.destroy_on_disconnect'); + assert.equal(queued[0][1].length, elems.length); + }); + check(`${game}: repeated first-spawn binding reuses panel and launches workers once`, () => { + const before = elems.length; context.hud_start(); context.hud_start(); + assert.equal(elems.length, before); + assert.deepEqual(queued.slice(1).map(item => item[0]), ['hud_timer_row', 'hud_zombie_counter', 'hud_health_display']); + context.hud_timer_row(); assert.equal(level.match_start_time, 123456); assert.equal(level.round_start_time, 123456); + }); + check(`${game}: HP stays visible at full health and falls back without mutating maxhealth`, () => { + self.health = 100; const run = context.hud_health_display(); run.next(); + assert.equal(self.maxhealth, undefined); assert.equal(self.tws_hud_health.value, 100); + assert.equal(self.tws_hud_maxhealth.value, 100); assert.equal(self.tws_hud_health.alpha, 1); + assert.equal(self.tws_hud_bar.alpha, 1); assert.equal(self.tws_hud_bar.shader[1], 200); + const writes = self.tws_hud_health.writes.length; run.next(); assert.equal(self.tws_hud_health.writes.length, writes); + self.maxhealth = 250; self.health = 50; run.next(); + assert.equal(self.tws_hud_maxhealth.value, 250); assert.equal(self.tws_hud_bar.shader[1], 40); + assert.equal(self.tws_hud_bar.color, 'red'); + self.health = 0; run.next(); assert.equal(self.tws_hud_bar.alpha, 0); assert.equal(self.tws_hud_health.value, 0); + self.health = 250; run.next(); assert.equal(self.tws_hud_bar.alpha, 1); assert.equal(self.tws_hud_bar.color, 'green'); + assert.equal(self.tws_hud_health.writes.filter(item => item[0] === 'text').length, 1); + }); +} +console.log(`${checks} core HUD fixture checks passed (transpiled subset, not a GSC VM).`); diff --git a/tools/check_hud.mjs b/tools/check_hud.mjs new file mode 100644 index 0000000..4e2e708 --- /dev/null +++ b/tools/check_hud.mjs @@ -0,0 +1,302 @@ +// Execute the actual common GSC service functions with HUD/event shims. +// These fixtures do not emulate the GSC VM, fonts, safe areas or HUD pool limits. +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import vm from 'node:vm'; +import './check_core_hud.mjs'; + +const root = new URL('../', import.meta.url); +let checks = 0; +function check(name, run) { + try { run(); checks++; } + catch (error) { throw new Error(name, { cause: error }); } +} +const coroutineNames = new Set(['service_loop', 'service_disconnect', 'service_end_game', 'service_game_over_via']); +function translate(source) { + const literals = []; + const protectedSource = source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => { + if (token.startsWith('/')) return ''; + literals.push(token); + return `__literal_${literals.length - 1}__`; + }); + const headers = /^(\w+)\(([^\n]*)\)\s*\n\{/gm; + const result = []; + for (let match; (match = headers.exec(protectedSource));) { + const name = match[1]; + if (!/^(service_|help_|make_)/.test(name) && !['common_config', 'ensure_config', 'register_help_command', 'show_help', 'show_record'].includes(name)) continue; + if (name === 'help_chat_listener') continue; + let end = headers.lastIndex, depth = 1; + while (depth) { + assert(end < protectedSource.length, `unclosed ${name}`); + const ch = protectedSource[end++]; + if (ch === '{') depth++; + if (ch === '}') depth--; + } + let body = protectedSource.slice(headers.lastIndex, end - 1); + const locals = new Set([...body.matchAll(/(? x[1])); + body = body + .replace(/\(([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)\)/g, '[$1, $2, $3]') + .replace(/\.size\b/g, '.length') + .replace(/(self|level) endon\(([^;]+)\);/g, 'endon($1, $2);') + .replace(/self thread (\w+)\(([^;]*)\);/g, 'startThread($1, [$2]);') + .replace(/(self|level) waittill\(([^;]+)\);/g, 'yield { entity: $1, event: $2 };') + .replace(/(self|level) notify\(([^;]+)\);/g, 'emit($1, $2);') + .replace(/self (\w+)\(/g, '$1(') + .replace(/\bwait ([\d.]+);/g, 'yield { milliseconds: $1 * 1000 };') + .replace(/([\w.]+(?:\[[^\]\n]+\])?)\s+(SetText|SetValue|SetShader|Destroy)\(/g, '$1.$2(') + .replace(/__literal_(\d+)__/g, (_, i) => literals[Number(i)]); + result.push(`function${coroutineNames.has(name) ? '*' : ''} ${name}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`); + } + return result.join('\n'); +} + +const commands = [ + ['.help', 'List available commands'], ['.afk', 'Pause with protection'], + ['.back', 'Return from AFK'], ['.join', 'Join mid-round (newcomers only)'], + ['.leaders', 'Show the map record again'], ['.b', 'Show bank balance'], + ['.d ', 'Deposit points'], ['.w ', 'Withdraw points'], + ['.x', 'Extra command'], +]; +function engine(game, count = 8) { + const source = readFileSync(new URL(`${game}/scripts/tws_${game}_common.gsc`, root), 'utf8'); + let now = 0, running; + const self = { sessionstate: 'spectator' }, level = {}, tasks = [], elements = []; + function resume(task) { + const prior = running; + running = task; + const result = task.generator.next(); + task.done = result.done; + task.wait = result.value; + task.at = result.value?.milliseconds !== undefined ? now + result.value.milliseconds : Infinity; + running = prior; + } + function startThread(fn, args) { + const task = { generator: fn(...args), ends: [] }; + tasks.push(task); + resume(task); + } + function emit(entity, event) { + const listeners = tasks.filter(t => !t.done); + for (const task of listeners) if (task.ends.some(([e, n]) => e === entity && n === event)) task.done = true; + for (const task of listeners) if (!task.done && task.wait?.entity === entity && task.wait.event === event) resume(task); + } + function advance(milliseconds) { + const until = now + milliseconds; + for (;;) { + const next = tasks.filter(t => !t.done && t.at <= until).sort((a, b) => a.at - b.at)[0]; + if (!next) break; + now = next.at; + resume(next); + } + now = until; + } + function newElement(owner) { + assert.equal(owner, self, 'every HUD element must be client-owned'); + const element = { + alpha: 1, textCalls: 0, valueCalls: 0, destroyCalls: 0, + SetText(text) { assert(!this.destroyCalls); this.text = text; this.textCalls++; }, + SetValue(value) { assert(!this.destroyCalls); this.value = value; this.valueCalls++; }, + SetShader(shader, width, height) { assert(!this.destroyCalls); Object.assign(this, { shader, width, height }); }, + Destroy() { this.destroyCalls++; assert.equal(this.destroyCalls, 1, 'double destruction'); this.alpha = 0; }, + }; + elements.push(element); + return element; + } + const c = vm.createContext({ + self, level, NewClientHudElem: newElement, getTime: () => now, + spawnStruct: () => ({}), isDefined: v => v !== undefined && v !== null, + max: Math.max, int: Math.trunc, + getSubStr: (text, begin, end) => { assert(end <= text.length); return text.slice(begin, end); }, + endon: (entity, event) => running.ends.push([entity, event]), startThread, emit, + }); + vm.runInContext(translate(source), c); + c.common_config(); + for (const [command, description] of commands.slice(0, count).reverse()) c.register_help_command(command, description); + for (const event of ['end_game', 'game_ended', 'intermission']) startThread(c.service_game_over_via, [event]); + return { c, self, level, elements, tasks, advance, source, + help: () => c.show_help(), record: snapshot => c.show_record(snapshot), + event: name => emit(self, name), end: name => emit(level, name), + get state() { return self.tws_service; }, + get visible() { return elements.filter(e => e.alpha > 0 && !e.destroyCalls); }, + get textCalls() { return elements.reduce((n, e) => n + e.textCalls, 0); }, + }; +} +const snapshot = (lines = ['Alex, Sam, Jamie']) => ({ round: 82, saved_at: 0, lines }); +for (const game of ['t5', 't6']) { + for (const count of [1, 4, 5, 8, 9]) check(`${game} ${count} registry entries paginate, order and dismiss`, () => { + const e = engine(game, count); + assert.equal(e.elements.length, 0, 'service is lazy and independent of core'); + e.help(); + const s = e.state, background = [s.background.x, s.background.y, s.background.width, s.background.height]; + const pages = Math.ceil(count / 4); + assert.equal(s.help.pages, pages); + assert.deepEqual(Array.from(s.commands, entry => entry.command), commands.slice(0, count).map(entry => entry[0])); + for (let page = 0; page < pages; page++) { + assert.equal(s.help.page, page); + assert.equal(s.page_number.value, page + 1); + assert.equal(s.page_total.value, pages); + assert.equal(s.command_column.text.split('\n').length, Math.min(4, count - page * 4)); + assert.equal(s.description_column.text.split('\n').length, Math.min(4, count - page * 4)); + assert.deepEqual([s.background.x, s.background.y, s.background.width, s.background.height], background); + e.advance(12000); + } + assert.equal(s.help, undefined); + assert.equal(e.visible.length, 0); + }); + check(`${game} repeated help reuses strings/elements and resets page one`, () => { + const e = engine(game); + e.help(); + const count = e.elements.length, texts = e.textCalls; + e.help(); + assert.equal(e.textCalls, texts, 'repeating current page does not reassign text'); + e.advance(12000); + assert.equal(e.state.help.page, 1); + e.help(); + assert.equal(e.state.help.page, 0); + e.advance(11900); + e.help(); + e.advance(100); + assert.equal(e.state.help.page, 0, 'old expiry cannot advance replacement'); + e.advance(23900); + assert.equal(e.state.help, undefined); + assert.equal(e.elements.length, count); + assert.equal(e.textCalls, texts + 6, 'only three page transitions update the two text columns'); + }); + check(`${game} help pauses record time and resumes the same page`, () => { + const e = engine(game); + e.record(snapshot(['One', 'Two', 'Three', 'Four'])); + e.advance(15000); + assert.equal(e.state.record.page, 1); + const remaining = e.state.record.remaining; + e.help(); + const texts = e.textCalls; + e.advance(24000); + assert.equal(e.state.help, undefined); + assert.equal(e.state.record.page, 1); + assert.equal(e.state.record.remaining, remaining); + assert.equal(e.state.rows[0].text, 'Four'); + assert.equal(e.textCalls, texts + 2, 'only the help page transition changes text; record resumption does not'); + e.advance(remaining); + assert.equal(e.state.record, undefined); + assert.equal(e.visible.length, 0); + }); + check(`${game} latest record replaces pending one without disturbing help`, () => { + const e = engine(game); + e.help(); + e.advance(3000); + const help = e.state.help; + e.record(snapshot(['Old'])); + e.record(snapshot(['Newest'])); + assert.equal(e.state.help, help); + assert.equal(e.state.record.page, 0); + assert.equal(e.state.record.remaining, 12000); + assert.equal(e.state.rows.filter(row => row.alpha > 0).length, 0); + e.advance(21000); + assert.equal(e.state.rows[0].text, 'Newest'); + assert.equal(e.state.record.remaining, 12000); + e.advance(11900); + e.record(snapshot(['Newest'])); + const texts = e.textCalls; + e.advance(100); + assert(e.state.record, 'previous expiry cannot hide newer request'); + assert.equal(e.textCalls, texts); + }); + check(`${game} no record, unavailable and short final record page are distinct`, () => { + const e = engine(game); + e.record(undefined); + assert.equal(e.state.unavailable.alpha, 1); + assert.equal(e.state.empty.alpha, 0); + e.record({ round: 0, lines: [] }); + assert.equal(e.state.unavailable.alpha, 0); + assert.equal(e.state.empty.alpha, 1); + e.record(snapshot(['A', 'B', 'C', 'D'])); + assert.equal(e.state.empty.alpha, 0); + assert.equal(e.state.round_number.value, 82); + e.advance(12000); + assert.deepEqual(Array.from(e.state.rows, row => row.alpha), [1, 0, 0]); + assert.equal(e.state.rows[0].text, 'D'); + }); + for (const event of ['end_game', 'game_ended', 'intermission', 'disconnect']) check(`${game} ${event} cleans active and pending pages once`, () => { + const e = engine(game); + e.record(snapshot()); + e.help(); + e.advance(2000); + if (event === 'disconnect') e.event(event); else e.end(event); + assert(e.state.closed); + assert.equal(e.state.help, undefined); + assert.equal(e.state.record, undefined); + assert(e.elements.every(elem => elem.destroyCalls === 1)); + const count = e.elements.length; + e.advance(60000); + if (event !== 'disconnect') { + e.help(); e.record(snapshot()); e.event('disconnect'); + assert.equal(e.elements.length, count, 'game end blocks new service requests'); + } + assert(e.elements.every(elem => elem.destroyCalls === 1)); + }); + check(`${game} ending before first service blocks lazy creation`, () => { + const e = engine(game); + e.end('end_game'); e.help(); e.record(snapshot()); + assert.equal(e.elements.length, 0); + }); + check(`${game} no per-tick text updates, stable core origin and private elements`, () => { + const e = engine(game); + e.record(snapshot()); + const calls = e.textCalls, count = e.elements.length; + e.advance(11000); + assert.equal(e.textCalls, calls); + assert.equal(e.elements.length, count); + assert.equal(e.state.background.horzAlign, 'right'); + assert.equal(e.state.background.vertAlign, game === 't5' ? 'top' : 'fullscreen'); + assert.equal(e.state.background.y, e.level.hud_panel_top + e.level.hud_service_y); + assert(e.state.background.y > e.level.hud_panel_top + e.level.hud_core_height); + assert.equal(e.state.commands[3].command, '.join'); + assert.match(e.c.help_description(e.state.commands[3]), /newcomer/); + assert.equal(e.c.help_command_label(e.state.commands[6].command), '.d '); + assert.equal(e.elements.length, 19, 'service allocation is bounded even when hidden'); + assert(e.elements.every(elem => elem.archived === false)); + }); + check(`${game} nominal font boxes fit the fixed panels without row collisions`, () => { + const e = engine(game); + e.record(snapshot(['One', 'Two', 'Three'])); + // Stock T6 _hud uses level.fontheight=12. This catches the original + // 11-unit holder pitch / overflowing AFK footer, not actual glyph fit. + const height = elem => Math.trunc(12 * elem.fontScale); + const s = e.state; + assert(s.holders_label.y + height(s.holders_label) <= s.rows[0].y); + for (let i = 1; i < s.rows.length; i++) + assert(s.rows[i - 1].y + height(s.rows[i - 1]) <= s.rows[i].y); + assert(s.rows.at(-1).y + height(s.rows.at(-1)) <= s.record_footer.y); + assert(s.record_footer.y + height(s.record_footer) < s.background.y + s.background.height); + assert(e.level.hud_row_bar + e.level.hud_bar_height < e.level.hud_row_afk); + assert(e.level.hud_row_afk + Math.trunc(12 * e.level.hud_font_afk) < e.level.hud_core_height); + e.help(); + const helpHeight = 4 * height(s.command_column); + assert(s.command_column.y + helpHeight <= s.command_back.y + s.command_back.height); + assert(s.command_column.y + helpHeight <= s.help_footer.y); + }); + check(`${game} script entrypoints and common references resolve`, () => { + const functions = new Set([...eSource(game).matchAll(/^(\w+)\([^\n]*\)\s*\n\{/gm)].map(m => m[1])); + const folder = new URL(`${game}/scripts/`, root); + const scripts = readdirSync(folder).filter(file => file.endsWith('.gsc')); + assert.equal(scripts.length, 8); + let connected = 0; + for (const file of scripts) { + const source = readFileSync(new URL(file, folder), 'utf8'); + assert.match(source, /^init\(\)/m); + if (game === 't6') { + assert.match(source, /^main\(\)/m); + assert(!/scripts\\sp\\|maps\\_utility|#"zombiemode"/.test(source)); + } + connected += (source.match(/waittill\("connected"/g) ?? []).length; + for (const ref of source.matchAll(/tws_t[56]_common::(\w+)/g)) assert(functions.has(ref[1]), `${file} missing ${ref[1]}`); + } + assert.equal(connected, 1); + }); +} +function eSource(game) { return readFileSync(new URL(`${game}/scripts/tws_${game}_common.gsc`, root), 'utf8'); } +check('paired service behavior remains identical', () => { + const section = game => eSource(game).slice(eSource(game).indexOf('show_help()\n{'), eSource(game).indexOf('// The only connected listener;')); + assert.equal(section('t5'), section('t6')); +}); +console.log(`PASS ${checks} service HUD fixture/static checks (both games). GSC compilation and Plutonium rendering remain separate.`); diff --git a/tools/check_leaderboard.mjs b/tools/check_leaderboard.mjs index 06ca141..332565d 100644 --- a/tools/check_leaderboard.mjs +++ b/tools/check_leaderboard.mjs @@ -44,7 +44,7 @@ function functions(source) { function engine(game) { const source = readFileSync(new URL(`${game}/scripts/tws_${game}_leaderboard.gsc`, root), 'utf8'); const all = functions(source); - const excluded = new Set(['lb_worker', 'lb_welcome', 'lb_get', 'lb_collect', 'lb_log', 'lb_show_hud', 'lb_hud_help_visible', 'lb_hud_end_game', 'lb_hud_destroy', 'lb_chat_listener', 'lb_request', 'lb_hud_disconnect']); + const excluded = new Set(['lb_worker', 'lb_welcome', 'lb_get', 'lb_collect', 'lb_log', 'lb_chat_listener', 'lb_request']); const table = []; for (let i = 1; i < 256; i++) table[String.fromCharCode(i)] = i; const dvars = { mapname: game === 't5' ? 'zombie_theater' : 'zm_transit', ui_gametype: 'zstandard', ui_zm_mapstartlocation: 'town' }; @@ -111,6 +111,42 @@ for (const game of ['t5', 't6']) { c.level.tws_lb_cache = undefined; assert.equal(queries.length, 0); }); + check(`${game} common owns the record service window`, () => { + const commonPath = game === 't5' ? 'scripts\\sp\\tws_t5_common' : 'scripts\\zm\\tws_t6_common'; + assert(source.includes(`self ${commonPath}::show_record(snapshot);`)); + assert(!source.includes('lb_show_hud')); + assert(!source.includes('lb_hud_')); + assert(!source.includes('tws_help_elems')); + assert(!source.includes('iPrintLnBold')); + assert(!source.includes('make_text_elem')); + assert(!source.includes('tws_t5_hud') && !source.includes('tws_t6_hud')); + }); + check(`${game} manual missing/stale requests cancel welcome and reach the service`, () => { + const calls = []; + c.self = { notify: (event) => calls.push(['notify', event]) }; + c.recordService = (snapshot) => calls.push(['record', snapshot]); + const request = all.get('lb_request') + .replace(/\b(?:self|level) endon\([^;]*\);/g, '') + .replace(/\bself notify\(/g, 'self.notify(') + .replace(/\bself scripts\\(?:sp|zm)\\tws_t[56]_common::show_record\(/g, 'recordService('); + vm.runInContext(request, c); + for (const cached of [undefined, { saved_at: 1, round: 52, lines: ['Alice'] }]) { + c.level.tws_lb_cache = cached; + c.lb_request(false); + assert.deepEqual(calls, []); + c.lb_request(true); + assert.deepEqual(calls, [['notify', 'tws_lb_requested'], ['record', undefined]]); + calls.length = 0; + } + const snapshot = { saved_at: 1000000, round: 0, lines: [] }; + c.level.tws_lb_cache = snapshot; + c.lb_request(false); + assert.deepEqual(calls, [['record', snapshot]]); + calls.length = 0; + c.lb_request(true); + assert.deepEqual(calls, [['notify', 'tws_lb_requested'], ['record', snapshot]]); + c.level.tws_lb_cache = undefined; + }); check(`${game} unicode and URL encoding`, () => { assert.equal(c.lb_url_encode(bytes('t6:zm_transit:town /?&=#é')), 't6%3Azm_transit%3Atown%20%2F%3F%26%3D%23%C3%A9'); assert.equal(text(c.lb_display_text(bytes('^1Alice\n^7 René 🧟'), 64)), 'Alice René 🧟'); @@ -193,11 +229,33 @@ for (const game of ['t5', 't6']) { assert.equal(c.lb_refresh(), undefined); c.level.tws_lb_override = ''; }); - check(`${game} all holders fit bounded messages`, () => { + check(`${game} all holders fit bounded service rows`, () => { const names = Array.from({ length: 100 }, (_, i) => `Holder ${i}`); const lines = Array.from(c.lb_holder_lines(names)); - assert(lines.every((line) => line.length <= 100)); + assert(lines.every((line) => c.lb_holder_width(line) <= 16)); assert.deepEqual(lines.join(', ').split(', '), names); + assert.deepEqual(Array.from(c.lb_holder_lines([])), []); + assert.deepEqual(Array.from(c.lb_holder_lines(['Alex', 'Sam', 'Jamie'])), ['Alex, Sam, Jamie']); + }); + check(`${game} holder width counts glyphs and clips without breaking UTF-8`, () => { + const samples = ['M'.repeat(64), 'é'.repeat(32), '🧟'.repeat(16), '玩家名字'.repeat(5), + 'abcdefghijklééé', '^1Alice\n^7 René 🧟', '^1\n\t']; + for (const sample of samples) { + const value = c.lb_holder_name(bytes(sample)); + assert(c.lb_holder_width(value) <= 16, sample); + assert(c.lb_utf8_valid(value), sample); + assert(!text(value).includes('\uFFFD'), sample); + assert(!/[\x00-\x1f\x7f^]/.test(value), sample); + assert(value.length > 0, sample); + } + assert.equal(c.lb_holder_name('M'.repeat(64)), 'M'.repeat(13) + '...'); + assert.equal(text(c.lb_holder_name(bytes('🧟'.repeat(16)))), '🧟'.repeat(6) + '...'); + assert.equal(c.lb_holder_name('^1\n\t'), 'Unknown player'); + assert.equal(c.lb_holder_width(bytes('René 🧟')), 8); + const names = Array(100).fill(bytes('同じ名前'.repeat(5))); + const rows = Array.from(c.lb_holder_lines(names)); + assert.equal(rows.length, names.length, 'identical long names still represent every holder'); + assert(rows.every((line) => c.lb_holder_width(line) <= 16 && c.lb_utf8_valid(line))); }); if (game === 't6') { const variants = [ diff --git a/versions.json b/versions.json index 44f6775..7f813c9 100644 --- a/versions.json +++ b/versions.json @@ -1,18 +1,18 @@ { - "tws_t5_afk.gsc": "1.0.0", + "tws_t5_afk.gsc": "1.0.1", "tws_t5_bank.gsc": "2.0.3", - "tws_t5_common.gsc": "1.1.0", - "tws_t5_hud.gsc": "1.1.2", + "tws_t5_common.gsc": "1.2.0", + "tws_t5_hud.gsc": "1.2.0", "tws_t5_join.gsc": "1.0.0", - "tws_t5_leaderboard.gsc": "1.0.0", + "tws_t5_leaderboard.gsc": "1.1.0", "tws_t5_lock.gsc": "1.0.0", "tws_t5_telemetry.gsc": "1.0.0", - "tws_t6_afk.gsc": "1.0.0", + "tws_t6_afk.gsc": "1.0.1", "tws_t6_bank.gsc": "2.0.3", - "tws_t6_common.gsc": "1.1.0", - "tws_t6_hud.gsc": "1.1.2", + "tws_t6_common.gsc": "1.2.0", + "tws_t6_hud.gsc": "1.2.0", "tws_t6_join.gsc": "1.0.1", - "tws_t6_leaderboard.gsc": "1.0.0", + "tws_t6_leaderboard.gsc": "1.1.0", "tws_t6_lock.gsc": "1.0.0", "tws_t6_telemetry.gsc": "1.0.0", "t5-plugin": "1.2.0",