Simplify T5 and T6 HUD overlays and service displays

This commit is contained in:
2026-10-15 21:07:10 -04:00
parent 929bf0354b
commit 2b5b1955bc
15 changed files with 1934 additions and 643 deletions
+262
View File
@@ -4,8 +4,270 @@ 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-15
### Replace the dashboard panels with unboxed HUD overlays
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.4.0` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.4.0` | `t6/scripts/tws_t6_common.gsc` |
| `tws_t5_hud.gsc` | `1.4.0` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.4.0` | `t6/scripts/tws_t6_hud.gsc` |
| `tws_t5_afk.gsc` | `1.2.0` | `t5/scripts/tws_t5_afk.gsc` |
| `tws_t6_afk.gsc` | `1.2.0` | `t6/scripts/tws_t6_afk.gsc` |
- Both games use the supplied minimal layout: unlabeled match/round timers at
upper left, combined branding at top center, zombies above a slim health bar
at bottom center, and numeric current/maximum HP to its right only while
injured. Center the AFK indicator above the zombie count. Remove dashboard
backgrounds while retaining outlined text and health colors.
- Keep `.hud` as a private visibility toggle for the entire core and AFK
indicator. Hidden data updates, frozen game-over values, connection/spawn
lifecycle, AFK protection and stock HUD behavior are unchanged.
- Move services into an unboxed upper-right area below the Plutonium tag.
Records use a compact right-aligned `Map record · Round N` header and up to
three existing bounded holder lines, with no footer or single-page counter.
Record data, cache behavior, all tied holders and 12-second paging remain
unchanged; leaderboard source and versions are unchanged.
- Show all nine installed commands, including `.hud`, in one help list with
full `.d <amount|all>` / `.w <amount|all>` syntax. Both engines use explicit
command/description rows; extra registrations use pages of nine, with a
counter only for multi-page lists. Preserve private requests, help priority,
record pause/resume, replay replacement and 12-second page timing.
- Allocate only the active service view in both engines and destroy it when
idle or before switching kinds. Core + AFK use 11 + 1 archived elements;
nine-command help uses 19 nonarchived elements (20 with a multi-page counter),
and records use four (five with a multi-page counter). Idle holds none.
These counts do not establish live Plutonium rendering capacity.
- Common adds fullscreen overlay helpers using direct normalized `640 × 480`
positions and integer shader dimensions. Retain legacy panel helpers for
compatibility. Both HUD and AFK implementations use the new helpers.
- Validation: `node tools/check_hud.mjs` passed 15 core HUD/AFK and 56 service/
static checks (71 total). Leaderboard passed 143 checks, bank 42, join 17 and
telemetry 23 through their portable check scripts. JSON uniqueness, the
16-script manifest, all six version/changelog matches, 66 local documentation
links/anchors and `git diff --check` passed. Checks use engine shims; actual
EC bank integration was not run. GSC compilation was not run because
gsc-tool/PowerShell are unavailable; live Windows/Plutonium validation remains
outstanding.
- Compatibility: HUD `1.4.0` and AFK `1.2.0` require matching common `1.4.0+`.
Both engines receive companion changes; leaderboard `1.1.0` remains
compatible. No new Plutonium, native-plugin or backend requirement.
- Deployment: copy each game's common, HUD and AFK scripts together where
installed, then restart the map. No full server-process restart or plugin
update is required. No deployment performed. Windows/Plutonium layout,
element capacity, stock HUD overlap and lifecycle validation remain required.
## 2026-10-14
### Release T5 service elements when records or help close
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.3.1` | `t5/scripts/tws_t5_common.gsc` |
- Address core HUD elements disappearing when the first record announcement
opens and remaining absent after it closes. The previous T5 service allocated
all 24 help/record elements at once and retained them while idle. That pattern
is consistent with HUD allocation pressure; the live T5 SP capacity has not
been established.
- Allocate only the active service view, with at most 12 elements for help or
11 for records. Release the elements when the service becomes idle and before
switching between help and records. Reuse them for pages within the same
view, and render the page indicator as one cached text element. The single
controller, help priority, paused record timing and request replacement remain
intact. Core elements and their visibility setting are unchanged.
- T6 needs no source change: the reported failure is on T5, whose explicit help
rows expanded its former allocation to 24 elements. T6 retains its smaller
18-element multiline renderer; validate its existing behavior separately.
- Validation: `node tools/check_hud.mjs` passed 15 core HUD/AFK and 53 service/
static checks (68 total), including active-view allocation, idle release,
repeated service cycles and core preservation. Leaderboard passed 143 checks,
bank 42, join 17 and telemetry 23 through their portable check scripts. These
use engine shims; actual EC bank integration was not run. Live Windows/
Plutonium rendering validation was not performed.
- Compatibility: HUD `1.3.0`, AFK `1.1.0` and leaderboard `1.1.0` remain
compatible. No new dependency requirement. Deployment: copy only
`tws_t5_common.gsc` and restart the map. No plugin update or full server-process
restart is required. No deployment performed; live T5 validation must confirm
core integrity during automatic records, help, view switches and dismissal.
### Add a per-player HUD visibility command
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.3.0` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.3.0` | `t6/scripts/tws_t6_common.gsc` |
| `tws_t5_hud.gsc` | `1.3.0` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.3.0` | `t6/scripts/tws_t6_hud.gsc` |
| `tws_t5_afk.gsc` | `1.1.0` | `t5/scripts/tws_t5_afk.gsc` |
| `tws_t6_afk.gsc` | `1.1.0` | `t6/scripts/tws_t6_afk.gsc` |
- Both HUD modules handle the exact, case-insensitive `.hud` chat command to
toggle that player's core branding, timers, zombie count, HP/bar and AFK
indicator. Visibility defaults on, persists across death/respawn on the
current connection/map, and resets on reconnect or map restart.
- Common tracks core elements and their intended alpha values so changing
visibility preserves each element's state. Data and timers continue updating
while hidden, and game-over values remain frozen. AFK routes only its
indicator visibility through these helpers; protection behavior is unchanged.
Help, records and stock HUD elements are unaffected.
- Register `.hud` in help only when the HUD module is installed. The full
command order is `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`, `.b`,
`.d`, `.w`: nine commands across pages of four, four and one. T5 retains
explicit help rows; T6 retains multiline columns.
- Compatibility: HUD `1.3.0` and AFK `1.1.0` require their matching common
`1.3.0+`. Both engines receive companion implementations. No new Plutonium,
native-plugin or backend dependency requirement.
- Deployment: update each game's common, HUD and AFK scripts together where
installed, then restart the map. No plugin update or full server-process
restart is required. No deployment performed; GSC compilation and live
T5/T6 validation remain required.
- Validation: `node tools/check_hud.mjs` passed 15 core HUD/AFK and 48 service/
static checks (63 total), covering private visibility, intended opacity,
late-created elements, hidden updates/frozen clocks, AFK transitions, service
independence, conditional help and three-page ordering. Leaderboard passed
143 checks, bank 42, join 17 and telemetry 23 through their existing portable
check scripts. These use engine shims; actual EC bank integration was not run.
GSC compiler and PowerShell were unavailable, and live Windows/Plutonium
validation was not performed.
### Render T5 help as separate rows
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.6` | `t5/scripts/tws_t5_common.gsc` |
- Replace T5's newline-separated help columns with four preallocated
command/description pairs, positioned on separate rows. This addresses BO1
rendering the newline text as one concatenated line. Reuse the row elements
across pages and hide unused rows on short pages.
- Preserve panel dimensions, four commands per page, 12-second timing and the
removed help footer. T5's service now uses 24 nonarchived HUD elements.
T6 needs no source change: its existing multiline renderer remains in place
with 18 service elements; the reported rendering issue was observed in T5.
- Validation: `node tools/check_hud.mjs` passed 8 core and 42 service checks
(50 total), covering both engines' pagination and T5's distinct row elements,
partial-page hiding, reuse, record transitions and disconnect cleanup.
GSC compilation and live T5 display/notification validation were not run.
- Compatibility: existing HUD `1.2.1` remains compatible; no new dependency
requirement. Deployment: copy only the updated T5 common script and restart
the map. No plugin update or full server-process restart is required.
No deployment performed; live T5 display validation remains required.
### Remove the help footer
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.5` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.5` | `t6/scripts/tws_t6_common.gsc` |
- Both games remove the `Four commands per page` footer and its HUD element.
Help still displays four commands per page with its page indicator and
existing timing. The shared service now uses 18 nonarchived elements.
- Validation: `node tools/check_hud.mjs` passed 8 core and 41 service checks
(49 total), including the 18-element allocation and removed help footer.
GSC compilation and live Plutonium validation were not run.
- Compatibility: existing HUD `1.2.1` remains compatible; no new dependency
requirement. Deployment: copy only each game's updated common script and
restart the map. No plugin update or full server-process restart is required.
No deployment performed; live T5/T6 display validation remains required.
### Hide the page indicator for single-page records
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.4` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.4` | `t6/scripts/tws_t6_common.gsc` |
- Both games hide `1 / 1` when the record service has only one page. Records
spanning multiple pages and all help pages retain their page indicators.
- Validation: `node tools/check_hud.mjs` passed 8 core and 41 service checks
(49 total), including empty/unavailable records, multi-page indicators,
replacement by a single-page record, resuming after help, and single-page
help indicators. GSC compilation and live Plutonium validation were not run.
- Compatibility: existing HUD `1.2.1` remains compatible; no new dependency
requirement. Deployment: copy only each game's updated common script and
restart the map. No plugin update or full server-process restart is required.
No deployment performed; live T5/T6 display validation remains required.
### Restore panel backgrounds and health bars
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.3` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.3` | `t6/scripts/tws_t6_common.gsc` |
- Both engines apply the final integer conversion after `max()` in
`make_panel_width()`. Native `max()` returns a float, which `SetShader`
rejects for its integer width argument; converting before `max()` left
backgrounds and health-bar shaders missing. The corrected helper supplies
integer widths to both panel creation and health-bar updates.
- Retain panel color `(0.07, 0.10, 0.08)`, opacity `0.72`, and the corrected
text positions/font sizes. HUD source is unchanged and remains `1.2.1`.
- Validation: `node tools/check_hud.mjs` passed 8 core and 41 service checks
(49 total). The new regression models native float typing and strict integer
shader widths, and checks the existing panel color/opacity. GSC compiler and
PowerShell were unavailable; GSC compilation and live Windows/Plutonium display
validation were not performed.
- Compatibility: use matching common `1.2.3+` with HUD `1.2.1`; no new
Plutonium, native-plugin or backend dependency requirement. Deployment: if
HUD `1.2.1` is already installed, copy only each game's updated common script
and restart the map. No plugin update or full server-process restart is
required. No deployment performed; verify restored shaders in T5 and T6.
### Correct footer font size and screen-edge panel placement
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.2` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.2` | `t6/scripts/tws_t6_common.gsc` |
| `tws_t5_hud.gsc` | `1.2.1` | `t5/scripts/tws_t5_hud.gsc` |
| `tws_t6_hud.gsc` | `1.2.1` | `t6/scripts/tws_t6_hud.gsc` |
- Both engines enforce a minimum text font scale of `1.0`, with both service
footers explicitly at `1.0`, addressing the oversized `.leaders` footer.
- Use fullscreen horizontal and vertical anchors so panels follow the actual
upper-right screen edge instead of safe-area insets. Convert horizontal
design offsets and shader widths by `0.75` into the normalized 640-wide
viewport; use a top offset of 20. The 1920×1080 reference yields a 495-pixel
panel, 22.5-pixel right margin and 45-pixel top margin, leaving space for the
Plutonium tag. Other aspect ratios retain normalized horizontal geometry,
so physical proportions vary; live font fit and tag clearance need validation.
- Both HUD scripts use common's new `make_panel_width(width)` for health-bar
updates so the fill uses the same width conversion as its track. T5 and T6
receive the same fix with their matching namespaces.
- Compatibility: HUD `1.2.1` requires matching common `1.2.2+`; upgrade common
and HUD together. No new Plutonium, native-plugin or backend dependency is
required.
- Deployment: copy each game's updated common and HUD scripts and restart the
map. No full server-process restart is required. No deployment performed;
GSC compilation and live Windows/Plutonium display validation remain required.
- Validation: `node tools/check_hud.mjs` passed 8 core and 39 service/static
geometry checks (47 total); `node tools/check_leaderboard.mjs` passed 143
checks. The geometry shims cover 4:3, 16:9 and ultrawide bounds in a normalized
640×480 frame, not live glyph fit or tag clearance. GSC compiler and PowerShell
were unavailable; no GSC compilation or live display validation was performed.
### Shorter map-record footer
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_common.gsc` | `1.2.1` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.2.1` | `t6/scripts/tws_t6_common.gsc` |
- Both games remove `Briefly shown` and its separator from the map-record
footer, retaining `.leaders to view again`. Record timing and replay behavior
are unchanged.
- Validation: `node tools/check_hud.mjs` passed 8 core and 35 service checks
using engine shims. GSC compilation and live Plutonium display validation
were not run.
- Compatibility: no new dependency requirement. Deployment: copy each game's
updated common script and restart the map. No plugin update or full
server-process restart is required. No deployment performed; GSC compilation
and live T5/T6 display validation remain required.
### Fixed upper-right core HUD and shared service window
| Component | Version | Affected source |
+92 -44
View File
@@ -361,39 +361,69 @@ settles legacy and public journals without duplicate ledger entries or lost poin
## 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 uses a light, unboxed layout: match time above round time at the upper
left, `TWS ZOMBIES · discord.tws.lol` centered along the top, and the zombie
count centered near the bottom above a slim health bar. Current/maximum HP
appears to the right of the bar only while injured, then disappears at full
health. The AFK indicator sits above the zombie count. Text uses an outline for
contrast; there are no panel backgrounds or timer labels.
Fullscreen X/Y anchors bypass safe-area insets. Overlay positions and shader
widths use the normalized `640 × 480` viewport directly. Text uses a minimum
font scale of `1.0`. Check the upper-left timers
against stock map inventory/objectives and performance overlays, and keep the
upper-right service area below the Plutonium tag. Physical proportions vary
with aspect ratio, so verify text fit and screen-edge clearance in-game.
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.
placeholders until the first-spawn callback binds the data displays. Creation
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.
Type `.hud` in chat to hide or show your core, including its branding, timers,
zombie count, health bar, damage-only HP and AFK indicator. The exact command
is case-insensitive. Visibility defaults on and persists across death/respawn
within the current connection/map; reconnecting or restarting the map restores
the visible default. Data and timers continue updating while hidden, so showing
the HUD restores current values or the frozen game-over values. Help, records
and the stock HUD remain unaffected.
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.
Common owns one unboxed service area at the upper right. `.help` and its
`.commands` alias show all nine installed commands in one list with command
and description columns: `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`,
`.b`, `.d`, `.w`. Missing modules' commands are omitted. Bank syntax remains
`.d <amount|all>` and `.w <amount|all>`, and `.join` identifies its newcomer
restriction. Additional registered commands use pages of nine rows; a page
count appears only when more than one page is needed. Each page lasts
12 seconds and the list closes after the final page. Repeating either help
command restarts page one. Both engines position rows explicitly, avoiding
T5's newline-rendering problem.
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.
Records use the same service area, right aligned: `Map record · Round N`
followed by up to three bounded holder lines. Single-page records have no
counter or footer; ties spanning multiple pages retain a page count and
12 seconds per page. 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. Services
remain private and work when the HUD module is omitted.
Both games allocate only the active service view and release it when idle or
before switching between help and records. Pages reuse the active view's
elements. The core uses 11 archived elements, plus one for AFK. The standard
nine-command help view uses 19 nonarchived elements, or 20 with a multi-page
counter; records use four, or five with a multi-page counter. Hidden elements
still occupy slots. The controller and pending requests are separate from
display lifetime, so an idle service holds no HUD elements.
HUD `1.4.0` and AFK `1.2.0` require matching common `1.4.0+` on both games.
Update common, HUD and AFK together where installed, then restart the map.
Leaderboard `1.1.0` remains compatible. There is no new Plutonium, native-plugin
or backend requirement, and this change does not require a full server-process
restart. Existing leaderboard plugin requirements still apply. Common retains
its older panel helpers for unchanged consumers; the new layout uses fullscreen
overlay helpers with the same integer shader-width handling.
### HUD validation
@@ -410,16 +440,23 @@ 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.
ammo and interaction prompts. Check upper-left timers, top-center branding,
bottom-center zombies/health and the upper-right service area. Text must stay
legible without panel backgrounds; services must clear the Plutonium tag.
Verify the health-bar fill stays within its track and damage-only HP clears
stock scores/ammo. Check text fit at other aspect ratios because horizontal
geometry scales with viewport width.
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.
health/Juggernog changes, and frozen timers at game over. HP numbers must
appear only while injured and disappear on full healing. Toggle AFK and back
without overlapping zombies/health or changing existing protection behavior.
3. Open `.help`, `.HELP` and `.commands` with all modules installed. Verify
one list of nine commands, full bank syntax, no single-page counter and
automatic dismissal after 12 seconds. Add custom registered commands to
exercise the nine-row page limit, page counter and repeat-to-restart behavior.
Remove optional modules and restart the map to confirm filtering, including
`.hud` disappearing without HUD, 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
@@ -428,6 +465,16 @@ Before rollout, validate both engines in Plutonium:
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.
6. Toggle `.hud` and `.HUD` before spawning, during play, while AFK and after
game over. Check private visibility, all core elements and the AFK indicator,
continued updates while hidden, and unchanged help/records/stock HUD. Verify
death/respawn preserves the choice and reconnect/map restart restores visibility.
7. On T5, watch the first automatic record announcement and its dismissal, then
repeat `.leaders`, help and interrupted/resumed records. Verify the core
health bar, damage-only HP, zombie count and timers remain intact while a
service is visible and after it closes. Include stock round/connection
notifications and other installed HUD mods; portable allocation checks do
not establish live T5 SP rendering capacity.
## Map record announcements
@@ -438,8 +485,8 @@ 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 service window identifies the record as unavailable and keeps
the `.leaders` retry reminder; this differs from a valid empty record history.
available, the service identifies the record as unavailable; this differs from
a valid empty record history. Neither state adds a replay footer.
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.
@@ -461,17 +508,18 @@ 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 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
initial record fetch if necessary. It appears as right-aligned text in the
common-owned upper-right service area: `Map record · Round N`, followed by up
to three holder lines. There is no background or replay footer, and a page
count appears only for multi-page records. Each page lasts 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.
lines and additional pages preserve every tied holder. The window closes when
the announcement finishes. Both games release their view elements when idle or
changing service kinds. Disconnect or game end cleans up any remaining 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.
+49 -18
View File
@@ -35,8 +35,9 @@ before copying the renamed scripts.
- **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.
Composite HP uses numeric elements around a static slash and `HP` suffix;
the group appears only while injured. Help reuses up to nine explicitly
positioned command/description pairs, 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
@@ -58,21 +59,39 @@ 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
- **Connection-time HUD creation must not seed clocks.** The unboxed core is
available to waiting spectators with placeholders: timers upper left,
branding top center and zombies/health bottom center. 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.
- **Visibility is separate from updates.** HUD owns the exact, case-insensitive
`.hud` chat command and its help entry. Common's core wrappers register each
element and preserve its intended alpha behind a per-player visibility mask;
health and AFK changes use `set_core_alpha()`. Hidden data/timers keep updating
until game over freezes them. Help, records and stock elements remain separate.
Visibility defaults on, survives death/respawn during the connection/map, and
resets on reconnect or map restart. AFK protection is unaffected.
- **One service view per player.** Common owns the unboxed upper-right service
area, ordered help rows, record display state and cleanup. Help shows all
nine installed commands; extra registrations use nine rows per page. Help
pauses record page time; `.leaders` during help replaces the pending replay
from page one. Keep both services independent of the HUD module. Services
never reposition the core, and AFK remains centered above the zombie count.
- **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
11 + 1 archived elements. Common allocates only the active service view:
19 nonarchived elements for nine-command help (20 with a multi-page counter),
or four for records (five with a multi-page counter), and none while idle.
Switching help/record views destroys the previous view first; pages reuse
its elements. Keep the controller and pending requests separate from element
lifetime so paused records resume normally. Both engines use explicit help
rows; BO1 can concatenate newline-separated HUD text. Hiding an element does
not free its slot. The previous 24-element persistent service allocation was
consistent with the reported core corruption after records first appeared.
The exact live T5 SP capacity remains unverified. 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.
comes from an MP-based reconstruction and is not proof of T5 SP limits or
current Plutonium stock usage. Validate automatic records, help, view switches
and dismissal alongside stock notifications and other installed HUD mods;
the core must remain intact throughout and after every service closes.
- **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")`
@@ -80,9 +99,21 @@ before copying the renamed scripts.
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.
HUD `1.4.0` and AFK `1.2.0` require matching common `1.4.0+`. Update all three
where installed and restart the map. Leaderboard `1.1.0` remains compatible.
The full help order is `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`,
`.b`, `.d`, `.w`: one page of nine commands, with full `<amount|all>` bank
syntax. `.hud` is absent when HUD is omitted. Records use right-aligned
`Map record · Round N` and up to three bounded holder lines, with no footer
and no single-page counter. Both views retain 12-second page timing.
Fullscreen X/Y overlay anchors use direct normalized `640 × 480` coordinates.
Dynamic health fills use `make_overlay_width()` like their tracks. Apply
`int()` after `max()` in that helper: native `max()` returns a float, but
`SetShader` requires an integer width. Legacy panel helpers and their `0.75`
horizontal conversion remain available for existing consumers. Font scale is
at least `1.0`. Numeric HP appears only while injured; the core and services
have no panel backgrounds. There is no new Plutonium/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.
@@ -91,7 +122,7 @@ do not replace T5 live loading, layout and lifecycle validation.
| File | Role | Fires notifies | Reads notifies |
|---|---|---|---|
| `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_hud` | Unboxed core overlays and private `.hud` visibility toggle | `hud_round_changed`, `hud_match_started`, `hud_game_over` | `say`, internal notifications |
| `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` |
+90 -42
View File
@@ -11,7 +11,7 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md).
| File | Role |
|---|---|
| [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_hud.gsc](tws_t5_hud.gsc) | Unboxed timers, branding, zombies/health and per-player `.hud` visibility toggle |
| [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 |
@@ -56,22 +56,43 @@ invalid identities fail privately without changing points. Follow the shared
## Core HUD and service window
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.
The unboxed core puts match time above round time at the upper left,
`TWS ZOMBIES · discord.tws.lol` at the top center, and zombies remaining above a
slim health bar at the bottom center. Current/maximum HP appears to the right
of the bar only while injured; it disappears at full health. The AFK indicator
sits above the zombie count. There are no panel backgrounds or timer labels.
Fullscreen X/Y anchors use the normalized `640 × 480` viewport directly and
bypass safe-area insets. Font scale is at least `1.0`; verify text fit, stock
HUD overlap and Plutonium tag clearance at each aspect ratio in-game.
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).
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.
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.
The exact, case-insensitive `.hud` command toggles your core and AFK indicator.
It defaults visible, keeps your choice across death/respawn on the current
connection/map, and resets visible on reconnect or map restart. Timers and data
continue updating while hidden; game-over values remain frozen. Help, records
and the stock HUD are unaffected. `.hud` requires the HUD module.
Common provides one unboxed upper-right service area below the Plutonium tag.
Records are right aligned, with `Map record · Round N` followed by up to three
holder lines; there is no replay footer or single-page counter. Help displays
the full nine-command list in aligned rows. Services remain private, leave the
core in place and work without the HUD module installed. See the
[shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6).
Only the active service view holds HUD elements: 19 for nine-command help
(20 with a multi-page counter), or four for records (five with a multi-page
counter). Pages reuse elements; switching views or closing the service releases
them. Idle services hold no HUD elements, while the controller retains pending
requests and interruption/resume behavior. Core + AFK use 11 + 1 elements.
HUD `1.4.0` and AFK `1.2.0` require common `1.4.0+`. Update common, HUD and AFK
together where installed, then restart the map. Leaderboard `1.1.0` remains
compatible. This change requires no new Plutonium/plugin/backend dependency or
full server-process restart.
## Map record announcements
@@ -93,14 +114,17 @@ 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 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.
Help uses the unboxed upper-right service area. All nine installed commands
fit in one list with command and description columns. Additional registered
commands use pages of nine rows, with a counter only for multi-page lists.
Pages advance every 12 seconds and close after the last page. Typing either
command again returns to page one with a fresh timer. Both engines use
explicit row positions so T5 does not concatenate newline-separated text.
| Command | Effect |
|---|---|
| `.help`, `.commands` | List available commands |
| `.hud` | Hide or show your core HUD and AFK indicator (HUD module required) |
| `.afk` | Pause with protection |
| `.back` | Return from AFK |
| `.join` | Join mid-round (newcomers only) |
@@ -112,6 +136,9 @@ page one with a fresh timer.
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.
With all command modules installed, help shows one page of nine commands,
including the full `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD
removes `.hud` from the list.
The enabled leaderboard module registers `.leaders`, which replays the latest
cached map record privately. Repeating it restarts the record from the first page;
help pauses the record's remaining page time and resumes it after the final
@@ -170,13 +197,15 @@ single `level waittill("connected")` in the fleet. Adding a second
connect listener anywhere else crashes the T5 SP handshake.
**Features**
- Shared HUD constants for panel geometry, rows/columns, fonts, health bar,
- Shared HUD constants for overlay positions, rows/columns, fonts, health bar,
colors and update rate
- Spawn-handler registry
- Connection-handler registry for optional modules that serve spectator newcomers
- `.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
- One service controller per player, with help/record pagination,
interruption/resume, request replacement and disconnect/game-end cleanup;
display elements exist only for the active view and are released when idle
- Core element registry with per-element alpha state and a per-player visibility mask
- Connect listener + per-player dispatch: iterates
`level.tws_spawn_handlers` after `spawned_player` and threads each on
the player
@@ -191,8 +220,18 @@ 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 |
| `make_overlay_text(p, x, y, ax, s, c)` | Text at absolute fullscreen coordinates in the normalized `640 × 480` frame |
| `make_overlay_shader(p, x, y, w, h, c, alpha, sort)` | Shader at absolute fullscreen coordinates |
| `make_overlay_width(width)` | Clamp a normalized width and return the integer required by `SetShader` |
| `make_core_overlay_text(p, x, y, ax, s, c)` | Create overlay text registered for `.hud` visibility |
| `make_core_overlay_shader(p, x, y, w, h, c, alpha, sort)` | Create an overlay shader registered for `.hud` visibility |
| `make_panel_text(p, x, y, ax, s, c)` | Legacy text positioned relative to the former upper-right panel origin |
| `make_panel_shader(p, x, y, w, h, c, alpha, sort)` | Legacy shader positioned relative to that panel origin |
| `make_panel_width(width)` | Retained legacy design-width conversion for existing consumers |
| `make_core_text(p, x, y, ax, s, c)` | Retained legacy panel text with `.hud` visibility |
| `make_core_shader(p, x, y, w, h, c, alpha, sort)` | Retained legacy panel shader with `.hud` visibility |
| `set_core_alpha(p, elem, alpha)` | Preserve the element's intended alpha while applying the player's visibility choice |
| `toggle_core_hud()` | Toggle the player's registered core elements without stopping updates |
| `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 |
@@ -203,31 +242,39 @@ connect listener anywhere else crashes the T5 SP handshake.
**Live validation (requires a T5 server)**
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.
first spawn. Verify all nine commands appear in one unboxed list, private to
the requesting player. Check explicit rows, full bank syntax and the absence
of a single-page counter. HP should appear beside the bottom health bar only
while injured.
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. 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
3. Verify help closes after 12 seconds. Add custom registered commands to test
the nine-row page limit, counter and repeat-to-restart behavior. 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.
5. Watch the first automatic record announcement and its dismissal. Repeat
records, help and interrupted/resumed records alongside stock notifications.
Check that branding, the health bar, damage-only HP, zombie count and timers
remain correct during each view and after it closes.
Include other installed HUD mods; the live T5 SP element limit is unverified.
---
## tws_t5_hud.gsc
Fixed upper-right core panel. Per-player elements are created on connection;
Unboxed core overlays. 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)
- `TWS ZOMBIES` / `discord.tws.lol` header and reserved AFK footer
- Zombies-remaining counter
- Full-width health bar with green / amber / red ramp; current/maximum HP
remains visible at full health
- Upper-left match timer above round timer (round resets each round, both freeze on game over)
- `.hud` toggles this player's core and AFK indicator; data updates continue while hidden
- Top-center `TWS ZOMBIES · discord.tws.lol` branding
- Bottom-center zombies-remaining counter, with the AFK indicator above it
- Slim health bar with green / amber / red ramp; current/maximum HP appears
beside it only while injured
- Spectator placeholders without starting the match clock on connection
- Round-transition and game-over notify machinery
@@ -246,8 +293,9 @@ Connection handler registered: `::hud_connect` — creates the core with placeho
without seeding clocks; `hud_start` calls it as a fallback if needed.
**Depends on**
- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_panel_text`,
`make_panel_shader`, `destroy_on_disconnect`, `format_mmss`,
- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_core_overlay_text`,
`make_core_overlay_shader`, `make_overlay_width`, `set_core_alpha`, `toggle_core_hud`,
`destroy_on_disconnect`, `format_mmss`, `register_help_command`,
`register_spawn_handler`, `register_connect_handler`
- Every `level.hud_*` config value from `tws_t5_common`
- Stock BO1 state: `level.round_number`, `level.zombie_total`,
@@ -258,8 +306,8 @@ without seeding clocks; `hud_start` calls it as a fallback if needed.
## tws_t5_afk.gsc
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.
Self-invoked pause mode. Owns the `[AFK]` indicator element, centered above the
zombie count through common layout values. It does not require HUD.
**Features**
- Chat commands `.afk` and `.back` (via `level waittill("say")` — not
@@ -269,7 +317,7 @@ core's reserved footer through common layout values. It does not require HUD.
- On exit: reverse, then a 2s grace tail keeps invuln + ignoreme through
the target-reacquisition window
- `[AFK]` HUD element created per-player via spawn handler; alpha-toggled
by enter / exit
by enter / exit through common's core visibility helpers, respecting `.hud`
**Provides**
- Per-player: `self.hud_afk_on` (bool), `self.hud_afk_elem` (HUD element),
@@ -277,9 +325,9 @@ core's reserved footer through common layout values. It does not require HUD.
- Level var: `level.afk_grace_seconds` (default 2)
**Depends on**
- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_panel_text`,
`destroy_on_disconnect`, `register_spawn_handler`, `register_help_command`
- Shared config: core footer position, `level.hud_font_small`, `level.hud_c_afk`
- `scripts\sp\tws_t5_common::` — `ensure_config`, `make_core_overlay_text`,
`set_core_alpha`, `destroy_on_disconnect`, `register_spawn_handler`, `register_help_command`
- Shared config: overlay AFK position, `level.hud_font_afk`, `level.hud_c_afk`
- Stock BO1: `enableInvulnerability`, `disableInvulnerability`,
`freezeControls`, `.ignoreme`, `level waittill("say", message, player)`
+6 -6
View File
@@ -50,11 +50,11 @@ afk_spawn()
{
self endon("disconnect");
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 = scripts\sp\tws_t5_common::make_core_overlay_text(
self, level.hud_overlay_afk_x, level.hud_overlay_afk_y,
"center", 1.0, level.hud_c_afk);
afk SetText("[AFK]");
afk.alpha = 0;
scripts\sp\tws_t5_common::set_core_alpha(self, afk, 0);
self.hud_afk_elem = afk;
elems = [];
@@ -75,7 +75,7 @@ afk_enter()
self freezeControls(true);
if (isDefined(self.hud_afk_elem))
self.hud_afk_elem.alpha = 1;
scripts\sp\tws_t5_common::set_core_alpha(self, self.hud_afk_elem, 1);
self iPrintLnBold("AFK — type .back to return");
}
@@ -91,7 +91,7 @@ afk_exit()
self freezeControls(false);
if (isDefined(self.hud_afk_elem))
self.hud_afk_elem.alpha = 0;
scripts\sp\tws_t5_common::set_core_alpha(self, self.hud_afk_elem, 0);
self iPrintLnBold("Back in the fight");
+286 -121
View File
@@ -28,8 +28,7 @@ common_config()
if (isDefined(level.hud_x))
return;
// Existing generic helpers retain their screen-anchor conventions. Panels
// use their own right-edge origin and never push stock HUD areas around.
// Retain legacy panel coordinates for existing helper consumers.
level.hud_x = 10;
level.hud_y = 30;
// Retain the legacy fields for consumers of the original common config.
@@ -43,7 +42,10 @@ common_config()
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_top = 20;
// Fullscreen uses a 640x480 frame. Convert the panel's 480-height units
// to that frame at the 16:9 reference aspect (640 / (480 * 16 / 9)).
level.hud_panel_x_scale = 0.75;
level.hud_panel_width = 220;
level.hud_core_height = 100;
level.hud_service_y = 108;
@@ -73,6 +75,38 @@ common_config()
level.hud_c_low = (1, 0.2, 0.2);
level.hud_c_afk = (1, 0.8, 0.2);
// Minimal overlays use absolute coordinates in the fullscreen 640x480 frame.
level.hud_overlay_timer_x = 6;
level.hud_overlay_match_y = 8;
level.hud_overlay_round_y = 30;
level.hud_overlay_brand_x = 320;
level.hud_overlay_brand_y = 8;
level.hud_overlay_count_x = 300;
level.hud_overlay_count_y = 440;
level.hud_overlay_count_label_x = 306;
level.hud_overlay_count_label_y = 446;
level.hud_overlay_bar_x = 242;
level.hud_overlay_bar_y = 466;
level.hud_overlay_bar_width = 156;
level.hud_overlay_bar_height = 4;
level.hud_overlay_health_y = 460;
level.hud_overlay_health_x = 414;
level.hud_overlay_slash_x = 418;
level.hud_overlay_maxhealth_x = 445;
level.hud_overlay_hp_x = 449;
level.hud_overlay_afk_x = 320;
level.hud_overlay_afk_y = 421;
level.hud_service_left = 432;
level.hud_service_right = 633;
level.hud_service_description_x = 538;
level.hud_service_title_y = 54;
level.hud_service_help_y = 80;
level.hud_service_help_pitch = 24;
level.hud_service_help_rows = 9;
level.hud_service_record_y = 36;
level.hud_service_holders_y = 60;
level.hud_service_holders_pitch = 18;
if (!isDefined(level.tws_spawn_handlers))
level.tws_spawn_handlers = [];
}
@@ -137,7 +171,7 @@ show_help()
state = self.tws_service;
service_advance(state, getTime());
state.generation++;
state.help = service_request("help", service_page_count(state.commands.size, 4), state.generation);
state.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation);
service_render(state);
}
@@ -209,14 +243,15 @@ service_advance(state, now)
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;
if (command == ".hud") return 1;
if (command == ".afk") return 2;
if (command == ".back") return 3;
if (command == ".join") return 4;
if (command == ".leaders") return 5;
if (command == ".b") return 6;
if (command.size >= 3 && getSubStr(command, 0, 3) == ".d ") return 7;
if (command.size >= 3 && getSubStr(command, 0, 3) == ".w ") return 8;
return 9;
}
help_entries()
@@ -224,7 +259,7 @@ help_entries()
entries = [];
if (!isDefined(level.tws_help_commands))
return entries;
for (rank = 0; rank <= 8; rank++)
for (rank = 0; rank <= 9; rank++)
{
for (i = 0; i < level.tws_help_commands.size; i++)
{
@@ -238,9 +273,6 @@ help_entries()
help_command_label(command)
{
rank = help_rank(command);
if (rank == 6) return ".d <n|all>";
if (rank == 7) return ".w <n|all>";
return command;
}
@@ -248,13 +280,14 @@ 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";
if (rank == 1) return "Toggle core HUD";
if (rank == 2) return "Protect & freeze";
if (rank == 3) return "Return to play";
if (rank == 4) return "Join as newcomer";
if (rank == 5) return "View records";
if (rank == 6) return "Bank balance";
if (rank == 7) return "Deposit";
if (rank == 8) return "Withdraw";
return entry.description;
}
@@ -277,57 +310,82 @@ service_ensure()
state.record_text = [];
state.help_page = -1;
state.rows = [];
state.command_rows = [];
state.description_rows = [];
state.view_kind = undefined;
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);
}
// Allocate only the active view. Alpha-zero elements still occupy HUD slots.
service_view_create(state, request)
{
service_view_destroy(state);
state.view_kind = request.kind;
state.view_paged = request.pages > 1;
if (state.view_paged)
state.page_counter = service_text(self, state, level.hud_service_right,
level.hud_service_title_y, "right", 1.0, "", level.hud_c_dim);
if (request.kind == "help")
{
state.help_title = service_text(self, state, level.hud_service_left,
level.hud_service_title_y, "left", 1.1, "COMMANDS", level.hud_c_label);
rows = state.commands.size;
if (rows > level.hud_service_help_rows)
rows = level.hud_service_help_rows;
// Explicit lines also work on T5, which can collapse multiline strings.
for (i = 0; i < rows; i++)
{
y = level.hud_service_help_y + i * level.hud_service_help_pitch;
state.command_rows[i] = service_text(self, state, level.hud_service_left,
y, "left", 1.0, "", level.hud_c_count);
state.description_rows[i] = service_text(self, state, level.hud_service_description_x,
y, "left", 1.0, "", level.hud_c_label);
}
return;
}
state.record_title = service_text(self, state, level.hud_service_right,
level.hud_service_record_y, "right", 1.1, "", level.hud_c_count);
for (i = 0; i < 3; i++)
state.rows[i] = service_text(self, state, level.hud_service_right,
level.hud_service_holders_y + i * level.hud_service_holders_pitch,
"right", 1.05, "", level.hud_c_label);
if (isDefined(state.page_counter))
state.page_counter.y = level.hud_service_holders_y + 3 * level.hud_service_holders_pitch;
}
service_view_destroy(state)
{
for (i = 0; i < state.elems.size; i++)
{
if (isDefined(state.elems[i]))
state.elems[i] Destroy();
}
state.elems = [];
state.view_kind = undefined;
state.view_paged = undefined;
state.page_counter = undefined;
state.help_title = undefined;
state.command_rows = [];
state.description_rows = [];
state.record_title = undefined;
state.rows = [];
state.page_text = undefined;
state.record_header_text = undefined;
state.record_text = [];
state.help_page = -1;
}
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 = make_overlay_text(player, x, y, align, scale, colour);
elem.alpha = 0;
elem.archived = false;
if (text != "")
elem SetText(text);
state.elems[state.elems.size] = elem;
@@ -351,74 +409,70 @@ service_render(state)
state.rendered = true;
state.last_generation = generation;
state.last_page = page;
if (!isDefined(request))
{
service_view_destroy(state);
return;
}
paged = request.pages > 1;
if (!isDefined(state.view_kind) || state.view_kind != request.kind || state.view_paged != paged)
service_view_create(state, request);
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 (paged)
{
page_text = "" + (page + 1) + " / " + request.pages;
if (!isDefined(state.page_text) || state.page_text != page_text)
{
state.page_counter SetText(page_text);
state.page_text = page_text;
}
state.page_counter.alpha = 1;
}
if (request.kind == "help")
{
state.help_title.alpha = 1;
state.help_footer.alpha = 1;
for (i = 0; i < state.command_rows.size; i++)
{
index = page * level.hud_service_help_rows + i;
if (index >= state.commands.size)
continue;
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_rows[i] SetText(help_command_label(entry.command));
state.description_rows[i] SetText(help_description(entry));
}
state.command_rows[i].alpha = 1;
state.description_rows[i].alpha = 1;
}
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;
header = "Map record unavailable";
snapshot = request.snapshot;
if (!isDefined(snapshot))
if (isDefined(snapshot))
{
state.unavailable.alpha = 1;
return;
header = "No recorded high round yet";
if (snapshot.round > 0)
header = "Map record · Round " + snapshot.round;
}
if (snapshot.round == 0)
if (!isDefined(state.record_header_text) || state.record_header_text != header)
{
state.empty.alpha = 1;
return;
state.record_title SetText(header);
state.record_header_text = header;
}
state.round_label.alpha = 1;
state.round_number SetValue(snapshot.round);
state.round_number.alpha = 1;
state.holders_label.alpha = 1;
state.record_title.alpha = 1;
if (!isDefined(snapshot) || snapshot.round == 0)
return;
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);
@@ -461,12 +515,7 @@ service_destroy(state)
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_view_destroy(state);
}
service_game_over_via(event)
@@ -526,7 +575,8 @@ make_text_elem(player, x, y, align_x, scale, colour)
elem.x = x;
elem.y = y;
elem.foreground = 1;
elem.fontScale = scale;
// Sub-1.0 text can render oversized on clients; keep a safe minimum.
elem.fontScale = max(1.0, scale);
elem.alpha = 1;
elem.color = colour;
@@ -553,14 +603,59 @@ 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.
// Absolute fullscreen overlays leave stock HUD areas and the center clear.
make_overlay_text(player, x, y, align_x, scale, colour)
{
elem = make_text_elem(player, x, y, align_x, scale, colour);
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
elem.sort = 20;
elem.archived = true;
return elem;
}
make_overlay_shader(player, x, y, width, height, colour, alpha, sort)
{
elem = make_shader_elem(player, x, y);
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
elem.color = colour;
elem.alpha = alpha;
elem.sort = sort;
elem.archived = true;
elem SetShader("white", make_overlay_width(width), int(max(1, height)));
return elem;
}
make_overlay_width(width)
{
// Native max() returns a float; shader dimensions must be integers.
return int(max(1, width));
}
make_core_overlay_text(player, x, y, align_x, scale, colour)
{
elem = make_overlay_text(player, x, y, align_x, scale, colour);
register_core_elem(player, elem);
return elem;
}
make_core_overlay_shader(player, x, y, width, height, colour, alpha, sort)
{
elem = make_overlay_shader(player, x, y, width, height, colour, alpha, sort);
register_core_elem(player, elem);
return elem;
}
// Panel x/y are local to the upper-right core origin. Fullscreen bypasses the
// engine safe area. Horizontal conversion preserves the original size at 16:9;
// other aspects retain the right-edge anchor with proportional horizontal size.
make_panel_text(player, x, y, align_x, scale, colour)
{
elem = make_text_elem(player, x - level.hud_panel_width - level.hud_panel_right,
elem = make_text_elem(player, 640 + (x - level.hud_panel_width - level.hud_panel_right) * level.hud_panel_x_scale,
level.hud_panel_top + y, align_x, scale, colour);
elem.horzAlign = "right";
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
elem.sort = 20;
elem.archived = true;
return elem;
@@ -568,17 +663,87 @@ make_panel_text(player, x, y, align_x, scale, colour)
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,
elem = make_shader_elem(player, 640 + (x - level.hud_panel_width - level.hud_panel_right) * level.hud_panel_x_scale,
level.hud_panel_top + y);
elem.horzAlign = "right";
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
elem.color = colour;
elem.alpha = alpha;
elem.sort = sort;
elem.archived = true;
elem SetShader("white", width, height);
elem SetShader("white", make_panel_width(width), height);
return elem;
}
// Native shader widths are integers; dynamic fills use the same conversion.
make_panel_width(width)
{
// max() returns a float even for integer inputs; cast its final result.
return int(max(1, width * level.hud_panel_x_scale));
}
// Core elements share a visibility mask; services keep their independent alpha.
make_core_text(player, x, y, align_x, scale, colour)
{
elem = make_panel_text(player, x, y, align_x, scale, colour);
register_core_elem(player, elem);
return elem;
}
make_core_shader(player, x, y, width, height, colour, alpha, sort)
{
elem = make_panel_shader(player, x, y, width, height, colour, alpha, sort);
register_core_elem(player, elem);
return elem;
}
register_core_elem(player, elem)
{
live = [];
if (isDefined(player.tws_core_hud_elems))
{
for (i = 0; i < player.tws_core_hud_elems.size; i++)
{
old = player.tws_core_hud_elems[i];
if (isDefined(old))
live[live.size] = old;
}
}
live[live.size] = elem;
player.tws_core_hud_elems = live;
set_core_alpha(player, elem, elem.alpha);
}
set_core_alpha(player, elem, alpha)
{
if (!isDefined(elem))
return;
elem.tws_core_alpha = alpha;
elem.alpha = alpha;
if (isDefined(player.tws_hud_hidden) && player.tws_hud_hidden)
elem.alpha = 0;
}
toggle_core_hud()
{
hidden = false;
if (isDefined(self.tws_hud_hidden))
hidden = self.tws_hud_hidden;
self.tws_hud_hidden = !hidden;
if (!isDefined(self.tws_core_hud_elems))
return;
live = [];
for (i = 0; i < self.tws_core_hud_elems.size; i++)
{
elem = self.tws_core_hud_elems[i];
if (!isDefined(elem))
continue;
set_core_alpha(self, elem, elem.tws_core_alpha);
live[live.size] = elem;
}
self.tws_core_hud_elems = live;
}
destroy_on_disconnect(elems)
{
self waittill("disconnect");
+72 -47
View File
@@ -1,4 +1,4 @@
/* tws_t5_hud.gsc - fixed upper-right core HUD, visible from connection. */
/* tws_t5_hud.gsc - minimal edge HUD, toggleable with .hud. */
#include maps\_utility;
#include common_scripts\utility;
@@ -9,14 +9,34 @@ init()
return;
scripts\sp\tws_t5_common::ensure_config();
scripts\sp\tws_t5_common::register_help_command(".hud", "Toggle core HUD");
level thread round_watcher();
level thread game_over_watcher();
level thread hud_chat_listener();
scripts\sp\tws_t5_common::register_connect_handler(::hud_connect);
scripts\sp\tws_t5_common::register_spawn_handler(::hud_start);
}
hud_chat_listener()
{
for (;;)
{
level waittill("say", message, player);
if (!isDefined(player) || !isDefined(message))
continue;
if (toLower(message) == ".hud")
player thread hud_toggle();
}
}
hud_toggle()
{
self hud_connect();
self scripts\sp\tws_t5_common::toggle_core_hud();
}
// Connection construction never seeds clocks or reads player gameplay state.
// The first-spawn fallback also handles a spawn before the connect callback.
hud_connect()
@@ -27,48 +47,42 @@ hud_connect()
return;
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.tws_hud_match = self hud_text(level.hud_overlay_timer_x,
level.hud_overlay_match_y, "left", 1.4, level.hud_c_count, "--:--");
self.tws_hud_round = self hud_text(level.hud_overlay_timer_x,
level.hud_overlay_round_y, "left", 1.25, level.hud_c_dim, "--:--");
self hud_text(level.hud_overlay_brand_x, level.hud_overlay_brand_y,
"center", 1.1, level.hud_c_dim, "TWS ZOMBIES · discord.tws.lol");
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");
self.tws_hud_count = self hud_text(level.hud_overlay_count_x,
level.hud_overlay_count_y, "right", 1.5, level.hud_c_count, "--");
self hud_text(level.hud_overlay_count_label_x, level.hud_overlay_count_label_y,
"left", 1.05, level.hud_c_dim, "ZOMBIES");
// 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, "--");
// The readout appears only after a valid damaged-health sample.
self.tws_hud_health = self hud_text(level.hud_overlay_health_x,
level.hud_overlay_health_y, "right", 1.0, level.hud_c_count, "--");
self.tws_hud_slash = self hud_text(level.hud_overlay_slash_x,
level.hud_overlay_health_y, "left", 1.0, level.hud_c_count, "/");
self.tws_hud_maxhealth = self hud_text(level.hud_overlay_maxhealth_x,
level.hud_overlay_health_y, "right", 1.0, level.hud_c_count, "--");
self.tws_hud_hp = self hud_text(level.hud_overlay_hp_x,
level.hud_overlay_health_y, "left", 1.0, level.hud_c_count, "HP");
scripts\sp\tws_t5_common::set_core_alpha(self, self.tws_hud_health, 0);
scripts\sp\tws_t5_common::set_core_alpha(self, self.tws_hud_slash, 0);
scripts\sp\tws_t5_common::set_core_alpha(self, self.tws_hud_maxhealth, 0);
scripts\sp\tws_t5_common::set_core_alpha(self, self.tws_hud_hp, 0);
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);
track = scripts\sp\tws_t5_common::make_core_overlay_shader(
self, level.hud_overlay_bar_x, level.hud_overlay_bar_y,
level.hud_overlay_bar_width, level.hud_overlay_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_bar = scripts\sp\tws_t5_common::make_core_overlay_shader(
self, level.hud_overlay_bar_x, level.hud_overlay_bar_y,
level.hud_overlay_bar_width, level.hud_overlay_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);
@@ -76,7 +90,7 @@ hud_connect()
hud_text(x, y, align, scale, colour, text)
{
elem = scripts\sp\tws_t5_common::make_panel_text(self, x, y, align, scale, colour);
elem = scripts\sp\tws_t5_common::make_core_overlay_text(self, x, y, align, scale, colour);
elem SetText(text);
self.tws_hud_elems[self.tws_hud_elems.size] = elem;
return elem;
@@ -145,7 +159,7 @@ hud_timer_row()
{
self endon("disconnect");
// A visible panel for a waiting newcomer must not start a pre-set round 1.
// A visible HUD 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)
@@ -190,7 +204,7 @@ match_timer_bind(elem)
elem SetTimerUp(0);
}
// The panel remains visible at game over, with authoritative frozen clocks.
// Preserve authoritative frozen clocks when the core is shown after game over.
freeze_timers_on_game_over(match_elem, round_elem)
{
self endon("disconnect");
@@ -249,11 +263,12 @@ hud_health_display()
self endon("disconnect");
level endon("hud_game_over");
bar_width = level.hud_bar_width;
bar_width = level.hud_overlay_bar_width;
bar = self.tws_hud_bar;
cur = self.tws_hud_health;
slash = self.tws_hud_slash;
max_hp = self.tws_hud_maxhealth;
hp = self.tws_hud_hp;
last_width = -1;
last_health = -1;
last_max = -1;
@@ -272,16 +287,17 @@ hud_health_display()
if (frac > 1)
frac = 1;
width = int(max(frac * bar_width, 1));
width = scripts\sp\tws_t5_common::make_overlay_width(frac * bar_width);
if (width != last_width)
{
bar SetShader("white", width, level.hud_bar_height);
bar SetShader("white", width, level.hud_overlay_bar_height);
last_width = width;
}
bar.alpha = 1;
bar_alpha = 1;
if (frac == 0)
bar.alpha = 0;
bar_alpha = 0;
scripts\sp\tws_t5_common::set_core_alpha(self, bar, bar_alpha);
colour = level.hud_c_high;
if (frac <= 0.25)
@@ -293,8 +309,17 @@ hud_health_display()
cur.color = colour;
slash.color = colour;
max_hp.color = colour;
hp.color = colour;
// Health numbers remain visible at full health.
readout_alpha = 0;
if (isDefined(self.health) && current < maximum)
readout_alpha = 1;
scripts\sp\tws_t5_common::set_core_alpha(self, cur, readout_alpha);
scripts\sp\tws_t5_common::set_core_alpha(self, slash, readout_alpha);
scripts\sp\tws_t5_common::set_core_alpha(self, max_hp, readout_alpha);
scripts\sp\tws_t5_common::set_core_alpha(self, hp, readout_alpha);
// Keep health numbers current even at full health or while hidden.
if (current != last_health)
{
cur SetValue(current);
+64 -31
View File
@@ -9,7 +9,7 @@ telemetry, leaderboard and bank modules; neither includes direct-database module
| --- | --- | --- |
| 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 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 positioning | Fullscreen X/Y anchors, direct normalized `640 × 480` coordinates, unboxed core and service area | Bypass safe-area insets; validate Plutonium tag clearance and font fit at each aspect ratio |
| 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 |
@@ -26,30 +26,56 @@ 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. 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.
registrations regardless of module initialization order. Common owns one
unboxed upper-right service area. Help shows all nine installed commands in
explicit command/description rows; extra registered commands use pages of nine
rows. Pages last 12 seconds, with automatic dismissal after the final page and
a counter only for multi-page lists. `.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. 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.
Both engines use explicit help rows; T5 no longer relies on newline rendering,
and T6 has the same row geometry. Common allocates only the active service
view: 19 nonarchived elements for nine-command help (20 with a multi-page
counter), or four for records (five with a multi-page counter). Closing the
service or switching kinds destroys the view; idle holds no elements. Pages
reuse the active elements, separately from controller and pending-request state.
Core + AFK use 11 + 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.
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. The unboxed layout has timers at the
upper left, branding at top center and zombies/health at bottom center. HP
numbers appear to the right of the bar only while injured; AFK sits above the
zombie count. Services never reposition the core. Records are right aligned,
with `Map record · Round N` followed by up to three bounded holder lines, no
footer and no single-page counter. Their 12-second page timing is unchanged.
HUD `1.4.0` and AFK `1.2.0` require matching common `1.4.0+`; update all three
where installed and restart the map. Leaderboard `1.1.0` remains compatible.
Overlay helpers use direct normalized `640 × 480` coordinates. Dynamic health
fills use `make_overlay_width()` like their tracks. Apply `int()` after `max()`
in that helper: native `max()` returns a float, but `SetShader` requires an
integer width. Legacy panel helpers retain their `0.75` horizontal conversion
for existing consumers. Font scale is at least `1.0`. This introduces no
Plutonium/native-plugin/backend dependency or full server-process restart
requirement.
HUD owns the exact, case-insensitive `.hud` chat command and registers it in
help only while installed. Common's core wrappers preserve each element's
intended alpha behind a per-player visibility mask; health and AFK changes use
`set_core_alpha()`. Data/timers keep updating while hidden and freeze at game
over. Help, records, stock HUD and AFK protection are unaffected. Visibility
defaults on, survives death/respawn during the connection/map, and resets on
reconnect or map restart. All nine installed commands use one help page,
ordered `.help`, `.hud`, `.afk`, `.back`, `.join`, `.leaders`, `.b`, `.d`, `.w`.
Bank commands display the full `<amount|all>` syntax.
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`
@@ -164,10 +190,11 @@ command regressions. Compiler success does not certify runtime linking.
Live checks after deploying:
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.
1. Join, late-join, die/respawn, and reconnect: one unboxed core per player,
upper-left timers, top-center branding, bottom-center zombies/health and
spectator placeholders without an early clock start. Check damage-only HP,
health/Juggernog changes, AFK placement and timers frozen at game over.
Test safe-area settings and different resolutions against stock HUD elements.
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.
@@ -176,20 +203,26 @@ Live checks after deploying:
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`, `.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.
while AFK: one page of nine commands with all modules installed, full bank
syntax and no page counter, only on the requesting player's screen. Verify
dismissal after 12 seconds and repeat-to-restart behavior. Add custom
registered commands to exercise the nine-row page limit and page counter.
Remove optional modules and restart the map to confirm their commands are
absent. Restore them and confirm 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.
8. Toggle `.hud` before spawning, while playing/AFK and after game over. Verify
the core and AFK indicator hide privately while help/records remain visible.
Show it after taking damage or changing AFK state while hidden: current
values and intended alpha should return. Confirm updates continue hidden,
death/respawn keeps the choice, and reconnect/map restart restores visibility.
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.
use that nominal box to prevent holder-row, help-row and AFK overlaps. Exact
glyph extents still need live validation, especially on T5.
+50 -22
View File
@@ -8,8 +8,8 @@ private server token.
| File | Role | Dependencies |
| --- | --- | --- |
| `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_hud.gsc` | Unboxed timers, branding, zombies/health and per-player `.hud` visibility toggle | Common 1.4.0+ |
| `tws_t6_afk.gsc` | `.afk` / `.back`, protection, movement and weapon blocking | Common 1.4.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 |
@@ -70,22 +70,43 @@ populates that field before the `connected` notification. Follow the shared
## Core HUD and service window
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.
The unboxed core puts match time above round time at the upper left,
`TWS ZOMBIES · discord.tws.lol` at the top center, and zombies remaining above a
slim health bar at the bottom center. Current/maximum HP appears to the right
of the bar only while injured; it disappears at full health. The AFK indicator
sits above the zombie count. There are no panel backgrounds or timer labels.
Fullscreen X/Y anchors use the normalized `640 × 480` viewport directly and
bypass safe-area insets. Font scale is at least `1.0`; verify text fit, stock
HUD overlap and Plutonium tag clearance at each aspect ratio in-game.
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).
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.
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.
The exact, case-insensitive `.hud` command toggles your core and AFK indicator.
It defaults visible, keeps your choice across death/respawn on the current
connection/map, and resets visible on reconnect or map restart. Timers and data
continue updating while hidden; game-over values remain frozen. Help, records
and the stock HUD are unaffected. `.hud` requires the HUD module.
Common provides one unboxed upper-right service area below the Plutonium tag.
Records are right aligned, with `Map record · Round N` followed by up to three
holder lines; there is no replay footer or single-page counter. Help displays
the full nine-command list in aligned rows. Services remain private, leave the
core in place and work without the HUD module installed. See the
[shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6).
Only the active service view holds HUD elements: 19 for nine-command help
(20 with a multi-page counter), or four for records (five with a multi-page
counter). Pages reuse elements; switching views or closing the service releases
them. Idle services hold no HUD elements, while the controller retains pending
requests and interruption/resume behavior. Core + AFK use 11 + 1 elements.
HUD `1.4.0` and AFK `1.2.0` require common `1.4.0+`. Update common, HUD and AFK
together where installed, then restart the map. Leaderboard `1.1.0` remains
compatible. This change requires no new Plutonium/plugin/backend dependency or
full server-process restart.
## Map record announcements
@@ -110,14 +131,17 @@ 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 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.
Help uses the unboxed upper-right service area. All nine installed commands
fit in one list with command and description columns. Additional registered
commands use pages of nine rows, with a counter only for multi-page lists.
Pages advance every 12 seconds and close after the last page. Typing either
command again returns to page one with a fresh timer. Both engines use
explicit row positions so T5 does not concatenate newline-separated text.
| Command | Description |
| --- | --- |
| `.help`, `.commands` | List available commands |
| `.hud` | Hide or show your core HUD and AFK indicator (HUD module required) |
| `.afk` | Pause with protection |
| `.back` | Return from AFK |
| `.join` | Join mid-round (newcomers only) |
@@ -126,11 +150,14 @@ page one with a fresh timer.
| `.d <amount\|all>` | Deposit points (bank module required) |
| `.w <amount\|all>` | Withdraw points (bank module required) |
AFK and Join register their commands during `init()` through
Command modules 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`. Commands follow the order above,
independent of initialization order. Register new commands alongside their
module's initialization so the list stays current.
With all command modules installed, help shows one page of nine commands,
including the full `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD
removes `.hud` from the list.
The enabled leaderboard module registers `.leaders`, which replays the latest
cached map record privately. Repeating it restarts the record from the first page;
help pauses the record's remaining page time and resumes it after the final
@@ -140,7 +167,8 @@ help page. `.leaders` during help replaces the pending record replay from page o
- 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. Core and service panels use fullscreen upper-right anchors.
load a config file. Core and service overlays use fullscreen X/Y anchors in
normalized `640 × 480` coordinates to bypass safe-area insets.
- 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.
+7 -7
View File
@@ -48,11 +48,11 @@ 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_panel_text(self,
level.hud_pad, level.hud_row_afk, "left",
level.hud_font_afk, level.hud_c_afk);
elem = scripts\zm\tws_t6_common::make_core_overlay_text(self,
level.hud_overlay_afk_x, level.hud_overlay_afk_y, "center",
1.0, level.hud_c_afk);
elem SetText("[AFK]");
elem.alpha = 0;
scripts\zm\tws_t6_common::set_core_alpha(self, elem, 0);
self.hud_afk_elem = elem;
elems = [];
elems[0] = elem;
@@ -84,7 +84,7 @@ afk_enter()
self.ignoreme = true;
self enableInvulnerability();
self afk_lock_controls();
self.hud_afk_elem.alpha = 1;
scripts\zm\tws_t6_common::set_core_alpha(self, self.hud_afk_elem, 1);
self iPrintLnBold("AFK - type .back to return");
}
@@ -95,7 +95,7 @@ afk_exit()
return;
self.hud_afk_on = false;
self afk_restore_controls();
self.hud_afk_elem.alpha = 0;
scripts\zm\tws_t6_common::set_core_alpha(self, self.hud_afk_elem, 0);
self.tws_afk_generation++;
generation = self.tws_afk_generation;
self iPrintLnBold("Back in the fight");
@@ -110,7 +110,7 @@ afk_release()
self.hud_afk_on = false;
self.tws_afk_protected = false;
self afk_restore_controls();
self.hud_afk_elem.alpha = 0;
scripts\zm\tws_t6_common::set_core_alpha(self, self.hud_afk_elem, 0);
// Stock scripts own protection during laststand/intermission/death.
if (scripts\zm\tws_t6_common::player_active(self))
{
+286 -121
View File
@@ -25,8 +25,7 @@ common_config()
if (isDefined(level.hud_x))
return;
// Existing generic helpers retain their screen-anchor conventions. Panels
// use their own right-edge origin and never push stock HUD areas around.
// Retain legacy panel coordinates for existing helper consumers.
level.hud_x = 10;
level.hud_y = 30;
// Retain the legacy fields for consumers of the original common config.
@@ -40,7 +39,10 @@ common_config()
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_top = 20;
// Fullscreen uses a 640x480 frame. Convert the panel's 480-height units
// to that frame at the 16:9 reference aspect (640 / (480 * 16 / 9)).
level.hud_panel_x_scale = 0.75;
level.hud_panel_width = 220;
level.hud_core_height = 100;
level.hud_service_y = 108;
@@ -70,6 +72,38 @@ common_config()
level.hud_c_low = (1, 0.2, 0.2);
level.hud_c_afk = (1, 0.8, 0.2);
// Minimal overlays use absolute coordinates in the fullscreen 640x480 frame.
level.hud_overlay_timer_x = 6;
level.hud_overlay_match_y = 8;
level.hud_overlay_round_y = 30;
level.hud_overlay_brand_x = 320;
level.hud_overlay_brand_y = 8;
level.hud_overlay_count_x = 300;
level.hud_overlay_count_y = 440;
level.hud_overlay_count_label_x = 306;
level.hud_overlay_count_label_y = 446;
level.hud_overlay_bar_x = 242;
level.hud_overlay_bar_y = 466;
level.hud_overlay_bar_width = 156;
level.hud_overlay_bar_height = 4;
level.hud_overlay_health_y = 460;
level.hud_overlay_health_x = 414;
level.hud_overlay_slash_x = 418;
level.hud_overlay_maxhealth_x = 445;
level.hud_overlay_hp_x = 449;
level.hud_overlay_afk_x = 320;
level.hud_overlay_afk_y = 421;
level.hud_service_left = 432;
level.hud_service_right = 633;
level.hud_service_description_x = 538;
level.hud_service_title_y = 54;
level.hud_service_help_y = 80;
level.hud_service_help_pitch = 24;
level.hud_service_help_rows = 9;
level.hud_service_record_y = 36;
level.hud_service_holders_y = 60;
level.hud_service_holders_pitch = 18;
if (!isDefined(level.tws_spawn_handlers))
level.tws_spawn_handlers = [];
}
@@ -133,7 +167,7 @@ show_help()
state = self.tws_service;
service_advance(state, getTime());
state.generation++;
state.help = service_request("help", service_page_count(state.commands.size, 4), state.generation);
state.help = service_request("help", service_page_count(state.commands.size, level.hud_service_help_rows), state.generation);
service_render(state);
}
@@ -205,14 +239,15 @@ service_advance(state, now)
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;
if (command == ".hud") return 1;
if (command == ".afk") return 2;
if (command == ".back") return 3;
if (command == ".join") return 4;
if (command == ".leaders") return 5;
if (command == ".b") return 6;
if (command.size >= 3 && getSubStr(command, 0, 3) == ".d ") return 7;
if (command.size >= 3 && getSubStr(command, 0, 3) == ".w ") return 8;
return 9;
}
help_entries()
@@ -220,7 +255,7 @@ help_entries()
entries = [];
if (!isDefined(level.tws_help_commands))
return entries;
for (rank = 0; rank <= 8; rank++)
for (rank = 0; rank <= 9; rank++)
{
for (i = 0; i < level.tws_help_commands.size; i++)
{
@@ -234,9 +269,6 @@ help_entries()
help_command_label(command)
{
rank = help_rank(command);
if (rank == 6) return ".d <n|all>";
if (rank == 7) return ".w <n|all>";
return command;
}
@@ -244,13 +276,14 @@ 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";
if (rank == 1) return "Toggle core HUD";
if (rank == 2) return "Protect & freeze";
if (rank == 3) return "Return to play";
if (rank == 4) return "Join as newcomer";
if (rank == 5) return "View records";
if (rank == 6) return "Bank balance";
if (rank == 7) return "Deposit";
if (rank == 8) return "Withdraw";
return entry.description;
}
@@ -273,57 +306,82 @@ service_ensure()
state.record_text = [];
state.help_page = -1;
state.rows = [];
state.command_rows = [];
state.description_rows = [];
state.view_kind = undefined;
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);
}
// Allocate only the active view. Alpha-zero elements still occupy HUD slots.
service_view_create(state, request)
{
service_view_destroy(state);
state.view_kind = request.kind;
state.view_paged = request.pages > 1;
if (state.view_paged)
state.page_counter = service_text(self, state, level.hud_service_right,
level.hud_service_title_y, "right", 1.0, "", level.hud_c_dim);
if (request.kind == "help")
{
state.help_title = service_text(self, state, level.hud_service_left,
level.hud_service_title_y, "left", 1.1, "COMMANDS", level.hud_c_label);
rows = state.commands.size;
if (rows > level.hud_service_help_rows)
rows = level.hud_service_help_rows;
// Explicit lines also work on T5, which can collapse multiline strings.
for (i = 0; i < rows; i++)
{
y = level.hud_service_help_y + i * level.hud_service_help_pitch;
state.command_rows[i] = service_text(self, state, level.hud_service_left,
y, "left", 1.0, "", level.hud_c_count);
state.description_rows[i] = service_text(self, state, level.hud_service_description_x,
y, "left", 1.0, "", level.hud_c_label);
}
return;
}
state.record_title = service_text(self, state, level.hud_service_right,
level.hud_service_record_y, "right", 1.1, "", level.hud_c_count);
for (i = 0; i < 3; i++)
state.rows[i] = service_text(self, state, level.hud_service_right,
level.hud_service_holders_y + i * level.hud_service_holders_pitch,
"right", 1.05, "", level.hud_c_label);
if (isDefined(state.page_counter))
state.page_counter.y = level.hud_service_holders_y + 3 * level.hud_service_holders_pitch;
}
service_view_destroy(state)
{
for (i = 0; i < state.elems.size; i++)
{
if (isDefined(state.elems[i]))
state.elems[i] Destroy();
}
state.elems = [];
state.view_kind = undefined;
state.view_paged = undefined;
state.page_counter = undefined;
state.help_title = undefined;
state.command_rows = [];
state.description_rows = [];
state.record_title = undefined;
state.rows = [];
state.page_text = undefined;
state.record_header_text = undefined;
state.record_text = [];
state.help_page = -1;
}
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 = make_overlay_text(player, x, y, align, scale, colour);
elem.alpha = 0;
elem.archived = false;
if (text != "")
elem SetText(text);
state.elems[state.elems.size] = elem;
@@ -347,74 +405,70 @@ service_render(state)
state.rendered = true;
state.last_generation = generation;
state.last_page = page;
if (!isDefined(request))
{
service_view_destroy(state);
return;
}
paged = request.pages > 1;
if (!isDefined(state.view_kind) || state.view_kind != request.kind || state.view_paged != paged)
service_view_create(state, request);
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 (paged)
{
page_text = "" + (page + 1) + " / " + request.pages;
if (!isDefined(state.page_text) || state.page_text != page_text)
{
state.page_counter SetText(page_text);
state.page_text = page_text;
}
state.page_counter.alpha = 1;
}
if (request.kind == "help")
{
state.help_title.alpha = 1;
state.help_footer.alpha = 1;
for (i = 0; i < state.command_rows.size; i++)
{
index = page * level.hud_service_help_rows + i;
if (index >= state.commands.size)
continue;
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_rows[i] SetText(help_command_label(entry.command));
state.description_rows[i] SetText(help_description(entry));
}
state.command_rows[i].alpha = 1;
state.description_rows[i].alpha = 1;
}
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;
header = "Map record unavailable";
snapshot = request.snapshot;
if (!isDefined(snapshot))
if (isDefined(snapshot))
{
state.unavailable.alpha = 1;
return;
header = "No recorded high round yet";
if (snapshot.round > 0)
header = "Map record · Round " + snapshot.round;
}
if (snapshot.round == 0)
if (!isDefined(state.record_header_text) || state.record_header_text != header)
{
state.empty.alpha = 1;
return;
state.record_title SetText(header);
state.record_header_text = header;
}
state.round_label.alpha = 1;
state.round_number SetValue(snapshot.round);
state.round_number.alpha = 1;
state.holders_label.alpha = 1;
state.record_title.alpha = 1;
if (!isDefined(snapshot) || snapshot.round == 0)
return;
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);
@@ -457,12 +511,7 @@ service_destroy(state)
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_view_destroy(state);
}
service_game_over_via(event)
@@ -523,7 +572,8 @@ make_text_elem(player, x, y, align_x, scale, colour)
elem.x = x;
elem.y = y;
elem.foreground = 1;
elem.fontScale = scale;
// Sub-1.0 text can render oversized on clients; keep a safe minimum.
elem.fontScale = max(1.0, scale);
elem.alpha = 1;
elem.color = colour;
@@ -550,14 +600,59 @@ 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.
// Absolute fullscreen overlays leave stock HUD areas and the center clear.
make_overlay_text(player, x, y, align_x, scale, colour)
{
elem = make_text_elem(player, x, y, align_x, scale, colour);
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
elem.sort = 20;
elem.archived = true;
return elem;
}
make_overlay_shader(player, x, y, width, height, colour, alpha, sort)
{
elem = make_shader_elem(player, x, y);
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
elem.color = colour;
elem.alpha = alpha;
elem.sort = sort;
elem.archived = true;
elem SetShader("white", make_overlay_width(width), int(max(1, height)));
return elem;
}
make_overlay_width(width)
{
// Native max() returns a float; shader dimensions must be integers.
return int(max(1, width));
}
make_core_overlay_text(player, x, y, align_x, scale, colour)
{
elem = make_overlay_text(player, x, y, align_x, scale, colour);
register_core_elem(player, elem);
return elem;
}
make_core_overlay_shader(player, x, y, width, height, colour, alpha, sort)
{
elem = make_overlay_shader(player, x, y, width, height, colour, alpha, sort);
register_core_elem(player, elem);
return elem;
}
// Panel x/y are local to the upper-right core origin. Fullscreen bypasses the
// engine safe area. Horizontal conversion preserves the original size at 16:9;
// other aspects retain the right-edge anchor with proportional horizontal size.
make_panel_text(player, x, y, align_x, scale, colour)
{
elem = make_text_elem(player, x - level.hud_panel_width - level.hud_panel_right,
elem = make_text_elem(player, 640 + (x - level.hud_panel_width - level.hud_panel_right) * level.hud_panel_x_scale,
level.hud_panel_top + y, align_x, scale, colour);
elem.horzAlign = "right";
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
elem.sort = 20;
elem.archived = true;
return elem;
@@ -565,17 +660,87 @@ make_panel_text(player, x, y, align_x, scale, colour)
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,
elem = make_shader_elem(player, 640 + (x - level.hud_panel_width - level.hud_panel_right) * level.hud_panel_x_scale,
level.hud_panel_top + y);
elem.horzAlign = "right";
elem.horzAlign = "fullscreen";
elem.vertAlign = "fullscreen";
elem.color = colour;
elem.alpha = alpha;
elem.sort = sort;
elem.archived = true;
elem SetShader("white", width, height);
elem SetShader("white", make_panel_width(width), height);
return elem;
}
// Native shader widths are integers; dynamic fills use the same conversion.
make_panel_width(width)
{
// max() returns a float even for integer inputs; cast its final result.
return int(max(1, width * level.hud_panel_x_scale));
}
// Core elements share a visibility mask; services keep their independent alpha.
make_core_text(player, x, y, align_x, scale, colour)
{
elem = make_panel_text(player, x, y, align_x, scale, colour);
register_core_elem(player, elem);
return elem;
}
make_core_shader(player, x, y, width, height, colour, alpha, sort)
{
elem = make_panel_shader(player, x, y, width, height, colour, alpha, sort);
register_core_elem(player, elem);
return elem;
}
register_core_elem(player, elem)
{
live = [];
if (isDefined(player.tws_core_hud_elems))
{
for (i = 0; i < player.tws_core_hud_elems.size; i++)
{
old = player.tws_core_hud_elems[i];
if (isDefined(old))
live[live.size] = old;
}
}
live[live.size] = elem;
player.tws_core_hud_elems = live;
set_core_alpha(player, elem, elem.alpha);
}
set_core_alpha(player, elem, alpha)
{
if (!isDefined(elem))
return;
elem.tws_core_alpha = alpha;
elem.alpha = alpha;
if (isDefined(player.tws_hud_hidden) && player.tws_hud_hidden)
elem.alpha = 0;
}
toggle_core_hud()
{
hidden = false;
if (isDefined(self.tws_hud_hidden))
hidden = self.tws_hud_hidden;
self.tws_hud_hidden = !hidden;
if (!isDefined(self.tws_core_hud_elems))
return;
live = [];
for (i = 0; i < self.tws_core_hud_elems.size; i++)
{
elem = self.tws_core_hud_elems[i];
if (!isDefined(elem))
continue;
set_core_alpha(self, elem, elem.tws_core_alpha);
live[live.size] = elem;
}
self.tws_core_hud_elems = live;
}
destroy_on_disconnect(elems)
{
self waittill("disconnect");
+72 -47
View File
@@ -1,4 +1,4 @@
/* tws_t6_hud.gsc - fixed upper-right core HUD, visible from connection. */
/* tws_t6_hud.gsc - minimal edge HUD, toggleable with .hud. */
#include maps\mp\_utility;
#include common_scripts\utility;
@@ -11,14 +11,34 @@ main()
init()
{
scripts\zm\tws_t6_common::ensure_config();
scripts\zm\tws_t6_common::register_help_command(".hud", "Toggle core HUD");
level thread round_watcher();
level thread game_over_watcher();
level thread hud_chat_listener();
scripts\zm\tws_t6_common::register_connect_handler(::hud_connect);
scripts\zm\tws_t6_common::register_spawn_handler(::hud_start);
}
hud_chat_listener()
{
for (;;)
{
level waittill("say", message, player);
if (!isDefined(player) || !isDefined(message))
continue;
if (toLower(message) == ".hud")
player thread hud_toggle();
}
}
hud_toggle()
{
self hud_connect();
self scripts\zm\tws_t6_common::toggle_core_hud();
}
// Connection construction never seeds clocks or reads player gameplay state.
// The first-spawn fallback also handles a spawn before the connect callback.
hud_connect()
@@ -29,48 +49,42 @@ hud_connect()
return;
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.tws_hud_match = self hud_text(level.hud_overlay_timer_x,
level.hud_overlay_match_y, "left", 1.4, level.hud_c_count, "--:--");
self.tws_hud_round = self hud_text(level.hud_overlay_timer_x,
level.hud_overlay_round_y, "left", 1.25, level.hud_c_dim, "--:--");
self hud_text(level.hud_overlay_brand_x, level.hud_overlay_brand_y,
"center", 1.1, level.hud_c_dim, "TWS ZOMBIES · discord.tws.lol");
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");
self.tws_hud_count = self hud_text(level.hud_overlay_count_x,
level.hud_overlay_count_y, "right", 1.5, level.hud_c_count, "--");
self hud_text(level.hud_overlay_count_label_x, level.hud_overlay_count_label_y,
"left", 1.05, level.hud_c_dim, "ZOMBIES");
// 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, "--");
// The readout appears only after a valid damaged-health sample.
self.tws_hud_health = self hud_text(level.hud_overlay_health_x,
level.hud_overlay_health_y, "right", 1.0, level.hud_c_count, "--");
self.tws_hud_slash = self hud_text(level.hud_overlay_slash_x,
level.hud_overlay_health_y, "left", 1.0, level.hud_c_count, "/");
self.tws_hud_maxhealth = self hud_text(level.hud_overlay_maxhealth_x,
level.hud_overlay_health_y, "right", 1.0, level.hud_c_count, "--");
self.tws_hud_hp = self hud_text(level.hud_overlay_hp_x,
level.hud_overlay_health_y, "left", 1.0, level.hud_c_count, "HP");
scripts\zm\tws_t6_common::set_core_alpha(self, self.tws_hud_health, 0);
scripts\zm\tws_t6_common::set_core_alpha(self, self.tws_hud_slash, 0);
scripts\zm\tws_t6_common::set_core_alpha(self, self.tws_hud_maxhealth, 0);
scripts\zm\tws_t6_common::set_core_alpha(self, self.tws_hud_hp, 0);
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);
track = scripts\zm\tws_t6_common::make_core_overlay_shader(
self, level.hud_overlay_bar_x, level.hud_overlay_bar_y,
level.hud_overlay_bar_width, level.hud_overlay_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_bar = scripts\zm\tws_t6_common::make_core_overlay_shader(
self, level.hud_overlay_bar_x, level.hud_overlay_bar_y,
level.hud_overlay_bar_width, level.hud_overlay_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);
@@ -78,7 +92,7 @@ hud_connect()
hud_text(x, y, align, scale, colour, text)
{
elem = scripts\zm\tws_t6_common::make_panel_text(self, x, y, align, scale, colour);
elem = scripts\zm\tws_t6_common::make_core_overlay_text(self, x, y, align, scale, colour);
elem SetText(text);
self.tws_hud_elems[self.tws_hud_elems.size] = elem;
return elem;
@@ -147,7 +161,7 @@ hud_timer_row()
{
self endon("disconnect");
// A visible panel for a waiting newcomer must not start a pre-set round 1.
// A visible HUD 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)
@@ -194,7 +208,7 @@ match_timer_bind(elem)
elem SetTimerUp(max(0, (getTime() - level.match_start_time) / 1000.0));
}
// The panel remains visible at game over, with authoritative frozen clocks.
// Preserve authoritative frozen clocks when the core is shown after game over.
freeze_timers_on_game_over(match_elem, round_elem)
{
self endon("disconnect");
@@ -238,11 +252,12 @@ hud_health_display()
self endon("disconnect");
level endon("hud_game_over");
bar_width = level.hud_bar_width;
bar_width = level.hud_overlay_bar_width;
bar = self.tws_hud_bar;
cur = self.tws_hud_health;
slash = self.tws_hud_slash;
max_hp = self.tws_hud_maxhealth;
hp = self.tws_hud_hp;
last_width = -1;
last_health = -1;
last_max = -1;
@@ -261,16 +276,17 @@ hud_health_display()
if (frac > 1)
frac = 1;
width = int(max(frac * bar_width, 1));
width = scripts\zm\tws_t6_common::make_overlay_width(frac * bar_width);
if (width != last_width)
{
bar SetShader("white", width, level.hud_bar_height);
bar SetShader("white", width, level.hud_overlay_bar_height);
last_width = width;
}
bar.alpha = 1;
bar_alpha = 1;
if (frac == 0)
bar.alpha = 0;
bar_alpha = 0;
scripts\zm\tws_t6_common::set_core_alpha(self, bar, bar_alpha);
colour = level.hud_c_high;
if (frac <= 0.25)
@@ -282,8 +298,17 @@ hud_health_display()
cur.color = colour;
slash.color = colour;
max_hp.color = colour;
hp.color = colour;
// Health numbers remain visible at full health.
readout_alpha = 0;
if (isDefined(self.health) && current < maximum)
readout_alpha = 1;
scripts\zm\tws_t6_common::set_core_alpha(self, cur, readout_alpha);
scripts\zm\tws_t6_common::set_core_alpha(self, slash, readout_alpha);
scripts\zm\tws_t6_common::set_core_alpha(self, max_hp, readout_alpha);
scripts\zm\tws_t6_common::set_core_alpha(self, hp, readout_alpha);
// Keep health numbers current even at full health or while hidden.
if (current != last_health)
{
cur SetValue(current);
+236 -57
View File
@@ -1,5 +1,6 @@
// Execute selected core HUD functions with engine-value shims.
// Execute selected core HUD/AFK functions with engine-value shims.
// These checks are not a GSC VM, compiler, linker, or live layout test.
// Common visibility helpers are stubbed here and executed directly by check_hud.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
@@ -10,86 +11,264 @@ 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--; }
while (depth) {
assert(end < source.length, `unclosed ${name}`);
const ch = source[end++];
if (ch === '{') depth++;
if (ch === '}') depth--;
}
return [match[1], source.slice(at, end - 1)];
}
function install(context, source, names) {
for (const name of names) {
const [args, original] = body(source, name);
let code = original.replace(/\/\/[^\n]*/g, '');
const locals = [...new Set([...code.matchAll(/(?<![\w.])(\w+)\s*(?:=(?!=)|\+\+|--)/g)].map(match => match[1]))];
code = code.replace(/(?:self|level) endon\([^;]+;/g, '')
.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::/g, 'common.')
.replace(/self thread ([\w.]+)\(([^;]*)\);/g, (_, fn, values) => `queued.push(['${fn}', ${values}]);`)
.replace(/self common\.toggle_core_hud\(\)/g, 'common.toggle_core_hud()')
.replace(/self ((?:hud_|afk_)\w+)\(/g, '$1(')
.replace(/self (\w+)\(/g, 'self.$1(')
.replace(/(\w+) (SetText|SetValue|SetTimerUp|SetShader)\(([^;]*)\);/g, '$1.$2($3);')
.replace(/level notify\(([^;]*)\);/g, 'level.notify($1);')
.replace(/level waittill\(([^;]*)\);/g, 'yield { event: $1 };')
.replace(/\.size\b/g, '.length')
.replace(/\((\d+\.\d+), (\d+\.\d+), (\d+\.\d+)\)/g, '[$1, $2, $3]')
.replace(/\bwait\s+([^;]+);/g, 'yield $1;');
vm.runInContext(`function${code.includes('yield ') ? '*' : ''} ${name}(${args}) { var ${locals.join(', ') || 'unused'}; ${code} }`, context);
}
}
function engine(game, source, afk, hidden = false) {
const queued = [], elems = [], actions = [];
let now = 123456;
const self = { active: true };
if (hidden) self.tws_hud_hidden = true;
for (const name of ['enableInvulnerability', 'disableInvulnerability', 'freezeControls',
'disableWeapons', 'disableOffhandWeapons', 'enableWeapons', 'enableOffhandWeapons', 'iPrintLnBold']) {
self[name] = (...args) => actions.push([name, ...args]);
}
function element(args, alpha = 1) {
const result = { args, alpha, 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; this.writes.push(['timer', value]); },
};
elems.push(result);
return result;
}
function register(player, elem) {
(player.tws_core_hud_elems ??= []).push(elem);
common.set_core_alpha(player, elem, elem.alpha);
return elem;
}
const common = {
make_core_overlay_text: (...args) => register(args[0], element(args)),
make_core_overlay_shader: (...args) => register(args[0], element(args, args[6])),
make_overlay_width: width => Math.trunc(Math.max(1, width)),
set_core_alpha(player, elem, alpha) {
if (!elem) return;
elem.tws_core_alpha = alpha;
elem.alpha = player.tws_hud_hidden ? 0 : alpha;
},
toggle_core_hud() {
self.tws_hud_hidden = !self.tws_hud_hidden;
for (const elem of self.tws_core_hud_elems ?? []) common.set_core_alpha(self, elem, elem.tws_core_alpha);
},
player_active: player => player.active,
format_mmss: ms => `${Math.floor(ms / 60000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`,
};
const level = { round_number: 1, hud_rate: .1, afk_grace_seconds: 2,
hud_overlay_timer_x: 6, hud_overlay_match_y: 8, hud_overlay_round_y: 30,
hud_overlay_brand_x: 320, hud_overlay_brand_y: 8,
hud_overlay_count_x: 300, hud_overlay_count_y: 440,
hud_overlay_count_label_x: 306, hud_overlay_count_label_y: 446,
hud_overlay_bar_x: 242, hud_overlay_bar_y: 466,
hud_overlay_bar_width: 156, hud_overlay_bar_height: 4,
hud_overlay_health_y: 460, hud_overlay_health_x: 414, hud_overlay_slash_x: 418,
hud_overlay_maxhealth_x: 445, hud_overlay_hp_x: 449,
hud_overlay_afk_x: 320, hud_overlay_afk_y: 421,
hud_c_count: 'white', hud_c_dim: 'dim', hud_c_afk: 'yellow',
hud_c_high: 'green', hud_c_mid: 'yellow', hud_c_low: 'red', notify() {} };
const context = vm.createContext({ self, level, common, queued, getTime: () => now,
isDefined: value => value !== undefined && value !== null, is_true: value => !!value,
int: Math.trunc, max: Math.max });
install(context, source, ['hud_connect', 'hud_text', 'hud_start', 'hud_toggle', 'hud_timer_row',
'hud_health_display', 'freeze_timers_on_game_over']);
install(context, afk, ['afk_spawn', 'afk_enter', 'afk_exit',
...(game === 't6' ? ['afk_release', 'afk_lock_controls', 'afk_restore_controls'] : [])]);
return { context, self, level, queued, elems, actions, common,
setTime(value) { now = value; },
spawnAfk() { const watcher = context.afk_spawn(); watcher?.next(); return watcher; },
};
}
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`, () => {
const make = hidden => engine(game, source, afk, hidden);
check(`${game}: native .hud chat and shared lifecycle retain engine-specific clock seeds`, () => {
assert(source.includes('register_help_command(".hud", "Toggle core HUD")'));
assert(source.includes('register_connect_handler(::hud_connect)'));
assert(source.includes('register_spawn_handler(::hud_start)'));
assert(!source.includes('waittill("connected"'));
const chat = body(source, 'hud_chat_listener')[1];
assert(chat.includes('level waittill("say", message, player)'));
assert(chat.includes('toLower(message) == ".hud"'));
assert(chat.includes('player thread hud_toggle()'), 'disconnect scope stays in the player worker');
assert(!body(source, 'hud_connect')[1].includes('getTime()'));
assert(!source.includes('tws_hud_hidden ='), 'spawn/construction must not reset the preference');
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'));
assert(afk.includes('make_core_overlay_text'));
assert(afk.includes('level.hud_overlay_afk_y'));
assert(!/\.alpha\s*=/.test(afk), 'AFK visibility must use the common mask');
});
const queued = [], elems = [];
const common = {
make_panel_text: (...args) => element(args),
make_panel_shader: (...args) => element(args),
check(`${game}: connection places 11 box-free core elements at the screen edges without seeding clocks`, () => {
const e = make(); e.context.hud_connect();
assert.equal(e.level.match_start_time, undefined); assert.equal(e.level.round_start_time, undefined);
assert.equal(e.self.tws_hud_match.text, '--:--'); assert.equal(e.self.tws_hud_health.text, '--');
assert.equal(e.elems.length, 11);
assert.equal(e.self.tws_core_hud_elems.length, 11);
e.context.hud_connect(); assert.equal(e.elems.length, 11);
assert.equal(e.queued.length, 1); assert.equal(e.queued[0][0], 'common.destroy_on_disconnect');
assert.equal(e.queued[0][1].length, e.elems.length);
const textAt = (text, x, y, align, scale, colour) => {
const elem = e.elems.find(item => item.text === text);
assert(elem, text); assert.deepEqual(elem.args.slice(1), [x, y, align, scale, colour]);
};
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;
assert.deepEqual(e.self.tws_hud_match.args.slice(1), [6, 8, 'left', 1.4, 'white']);
assert.deepEqual(e.self.tws_hud_round.args.slice(1), [6, 30, 'left', 1.25, 'dim']);
textAt('TWS ZOMBIES · discord.tws.lol', 320, 8, 'center', 1.1, 'dim');
assert.deepEqual(e.self.tws_hud_count.args.slice(1), [300, 440, 'right', 1.5, 'white']);
textAt('ZOMBIES', 306, 446, 'left', 1.05, 'dim');
textAt('HP', 449, 460, 'left', 1, 'white');
for (const [key, x, align] of [['health', 414, 'right'], ['slash', 418, 'left'], ['maxhealth', 445, 'right']]) {
const elem = e.self[`tws_hud_${key}`];
assert.deepEqual(elem.args.slice(1), [x, 460, align, 1, 'white']);
assert.equal(elem.alpha, 0); assert.equal(elem.tws_core_alpha, 0);
}
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(/(?<![\w.])(\w+)\s*=(?!=)/g)].map(match => 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);
assert.equal(e.self.tws_hud_hp.alpha, 0);
const shaders = e.elems.filter(elem => elem.args.length === 8);
assert.equal(shaders.length, 2, 'only the health track and fill allocate shaders');
for (const elem of shaders) assert.deepEqual(elem.args.slice(1, 5), [242, 466, 156, 4]);
assert.equal(shaders[0].alpha, .8); assert.equal(shaders[1].alpha, 0);
assert(!e.elems.some(elem => elem.text === 'MATCH' || elem.text === 'ROUND'));
});
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}: toggling before first spawn preserves hidden state and binds workers once`, () => {
const e = make(); e.context.hud_toggle();
assert.equal(e.self.tws_hud_hidden, true);
assert(e.elems.every(elem => elem.alpha === 0));
assert.equal(e.level.match_start_time, undefined); assert.equal(e.level.round_start_time, undefined);
assert.equal(e.queued.length, 1, 'toggle only queues disconnect cleanup');
e.context.hud_start(); e.context.hud_start();
assert.equal(e.elems.length, 11); assert.equal(e.self.tws_hud_hidden, true);
assert.deepEqual(e.queued.slice(1).map(item => item[0]), ['hud_timer_row', 'hud_zombie_counter', 'hud_health_display']);
e.context.hud_timer_row();
assert.equal(e.level.match_start_time, 123456); assert.equal(e.level.round_start_time, 123456);
const threads = e.queued.length;
e.context.hud_toggle(); e.context.hud_toggle(); e.context.hud_start();
assert.equal(e.queued.length, threads); assert.equal(e.elems.length, 11);
});
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);
check(`${game}: health numbers appear only while damaged, follow the bar colour, and avoid config-string churn`, () => {
const e = make(); e.context.hud_connect(); const { self } = e;
const numbers = [self.tws_hud_health, self.tws_hud_slash, self.tws_hud_maxhealth, self.tws_hud_hp];
const run = e.context.hud_health_display(); run.next();
assert(numbers.every(elem => elem.alpha === 0), 'undefined health does not flash a damage readout');
assert.equal(self.health, undefined); assert.equal(self.maxhealth, undefined);
self.health = 100; run.next();
assert.equal(self.tws_hud_health.value, 100); assert.equal(self.tws_hud_maxhealth.value, 100);
assert(numbers.every(elem => elem.alpha === 0));
assert.equal(self.tws_hud_bar.alpha, 1); assert.equal(self.tws_hud_bar.shader[1], 156);
const writes = self.tws_hud_health.writes.length; run.next(); assert.equal(self.tws_hud_health.writes.length, writes);
self.health = 35; run.next();
assert(numbers.every(elem => elem.alpha === 1 && elem.color === 'yellow'));
assert.equal(self.tws_hud_health.value, 35); assert.equal(self.tws_hud_bar.shader[1], 54);
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_maxhealth.value, 250); assert.equal(self.tws_hud_bar.shader[1], 31);
assert(numbers.every(elem => elem.alpha === 1 && elem.color === 'red'));
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);
assert(numbers.every(elem => elem.alpha === 1));
self.health = 250; run.next(); assert.equal(self.tws_hud_bar.alpha, 1); assert.equal(self.tws_hud_bar.color, 'green');
assert(numbers.every(elem => elem.alpha === 0 && elem.tws_core_alpha === 0));
self.health = 500; run.next(); assert.equal(self.tws_hud_bar.shader[1], 156);
assert(numbers.every(elem => elem.alpha === 0), 'overheal must not show the damage readout');
self.maxhealth = 0; self.health = 100; run.next();
assert.equal(self.maxhealth, 0, 'invalid maximum is display-only fallback');
assert.equal(self.tws_hud_maxhealth.value, 100); assert(numbers.every(elem => elem.alpha === 0));
assert.equal(self.tws_hud_health.writes.filter(item => item[0] === 'text').length, 1);
});
check(`${game}: hidden health updates remain private and restore the current bar state`, () => {
const e = make(true), other = make();
e.context.hud_connect(); other.context.hud_connect();
e.self.health = 100; const run = e.context.hud_health_display(); run.next();
assert(e.elems.every(elem => elem.alpha === 0));
assert.equal(e.self.tws_hud_bar.tws_core_alpha, 1);
e.self.health = 0; run.next(); e.context.hud_toggle();
assert.equal(e.self.tws_hud_health.value, 0); assert.equal(e.self.tws_hud_bar.alpha, 0);
assert.equal(e.elems[0].alpha, 1); assert.equal(e.elems.at(-2).alpha, .8);
e.context.hud_toggle(); e.self.health = 50; run.next();
assert.equal(e.self.tws_hud_bar.alpha, 0); assert.equal(e.self.tws_hud_bar.shader[1], 78);
e.context.hud_toggle();
assert.equal(e.self.tws_hud_bar.alpha, 1); assert.equal(e.self.tws_hud_health.value, 50);
assert.equal(other.self.tws_hud_hidden, undefined); assert.equal(other.self.tws_hud_health.alpha, 0);
assert.equal(e.elems.length, 11); assert.equal(e.queued.length, 1);
e.context.hud_toggle(); e.self.health = 100; run.next(); e.context.hud_toggle();
assert.equal(e.self.tws_hud_health.alpha, 0, 'healing while hidden conceals the readout when shown');
assert.equal(e.self.tws_hud_hp.alpha, 0); assert.equal(e.self.tws_hud_bar.alpha, 1);
});
check(`${game}: toggling frozen clocks does not restart or reseed them`, () => {
const e = make(); e.context.hud_start(); e.context.hud_timer_row();
const match = e.self.tws_hud_match, round = e.self.tws_hud_round;
const freeze = e.context.freeze_timers_on_game_over(match, round);
assert.equal(freeze.next().value.event, 'hud_game_over');
e.context.hud_toggle(); e.setTime(248456); e.level.tws_ended = true; freeze.next();
assert.equal(match.text, '2:05'); assert.equal(round.text, '2:05');
const before = JSON.stringify([match.writes, round.writes, e.queued.map(item => item[0])]);
e.setTime(999999); e.context.hud_toggle(); e.context.hud_start();
assert.equal(match.alpha, 1); assert.equal(round.alpha, 1);
assert.equal(JSON.stringify([match.writes, round.writes, e.queued.map(item => item[0])]), before);
assert.equal(e.level.match_start_time, 123456); assert.equal(e.level.round_start_time, 123456);
});
check(`${game}: inactive AFK stays hidden and protection survives HUD toggles`, () => {
const e = make(); e.context.hud_connect(); e.spawnAfk();
const indicator = e.self.hud_afk_elem;
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 0);
assert.deepEqual(indicator.args.slice(1), [320, 421, 'center', 1, 'yellow']);
e.context.hud_toggle(); e.context.hud_toggle(); assert.equal(indicator.alpha, 0);
e.context.hud_toggle(); e.context.afk_enter();
assert.equal(e.self.hud_afk_on, true); assert.equal(e.self.ignoreme, true);
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 1);
assert(e.actions.some(([name]) => name === 'enableInvulnerability'));
assert(e.actions.some(([name, enabled]) => name === 'freezeControls' && enabled));
const actions = e.actions.length;
e.context.hud_toggle(); assert.equal(indicator.alpha, 1); assert.equal(e.actions.length, actions);
e.context.hud_toggle(); const exit = e.context.afk_exit();
assert.equal(exit.next().value, 2);
assert.equal(indicator.alpha, 0); assert.equal(indicator.tws_core_alpha, 0);
assert.equal(e.self.ignoreme, true, 'the existing protection grace tail remains active');
e.context.hud_toggle(); assert.equal(indicator.alpha, 0);
exit.next(); assert.equal(e.self.ignoreme, false);
assert(e.actions.some(([name]) => name === 'disableInvulnerability'));
assert.equal(e.elems.length, 12);
});
if (game === 't6') check('t6: AFK release while hidden respects stock protection ownership', () => {
const e = make(); e.context.hud_connect(); const watcher = e.spawnAfk();
e.context.afk_enter(); e.context.hud_toggle(); e.self.active = false;
watcher.next();
assert.equal(e.self.hud_afk_on, false); assert.equal(e.self.tws_afk_protected, false);
assert.equal(e.self.hud_afk_elem.tws_core_alpha, 0);
assert.equal(e.self.ignoreme, true);
assert(!e.actions.some(([name]) => name === 'disableInvulnerability'));
e.context.hud_toggle(); assert.equal(e.self.hud_afk_elem.alpha, 0);
e.self.active = true; watcher.next();
assert.equal(e.self.tws_afk_controls_locked, false);
assert(e.actions.some(([name]) => name === 'enableWeapons'));
assert(e.actions.some(([name]) => name === 'enableOffhandWeapons'));
});
}
console.log(`${checks} core HUD fixture checks passed (transpiled subset, not a GSC VM).`);
console.log(`${checks} core HUD/AFK fixture checks passed (transpiled subset, not a GSC VM).`);
+349 -67
View File
@@ -1,5 +1,6 @@
// 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.
// These fixtures do not emulate the GSC VM, fonts or HUD pool limits. Geometry
// checks model the 640x480 fullscreen placement transform, not a live renderer.
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import vm from 'node:vm';
@@ -11,7 +12,7 @@ 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']);
const coroutineNames = new Set(['service_loop', 'service_disconnect', 'service_end_game', 'service_game_over_via', 'destroy_on_disconnect']);
function translate(source) {
const literals = [];
const protectedSource = source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => {
@@ -23,7 +24,7 @@ function translate(source) {
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 (!/^(service_|help_|make_)/.test(name) && !['common_config', 'ensure_config', 'register_help_command', 'show_help', 'show_record', 'register_core_elem', 'set_core_alpha', 'toggle_core_hud', 'hud_connect', 'hud_text', 'afk_spawn', 'destroy_on_disconnect'].includes(name)) continue;
if (name === 'help_chat_listener') continue;
let end = headers.lastIndex, depth = 1;
while (depth) {
@@ -35,6 +36,7 @@ function translate(source) {
let body = protectedSource.slice(headers.lastIndex, end - 1);
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(x => x[1]));
body = body
.replace(/scripts\\(?:sp|zm)\\tws_t[56]_common::/g, '')
.replace(/\(([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)\)/g, '[$1, $2, $3]')
.replace(/\.size\b/g, '.length')
.replace(/(self|level) endon\(([^;]+)\);/g, 'endon($1, $2);')
@@ -57,10 +59,11 @@ const commands = [
['.d <amount|all>', 'Deposit points'], ['.w <amount|all>', 'Withdraw points'],
['.x', 'Extra command'],
];
function engine(game, count = 8) {
function engine(game, count = 8, withHud = false) {
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_common.gsc`, root), 'utf8');
let now = 0, running;
let now = 0, running, peakLive = 0;
const self = { sessionstate: 'spectator' }, level = {}, tasks = [], elements = [];
const players = new Set([self]);
function resume(task) {
const prior = running;
running = task;
@@ -91,76 +94,190 @@ function engine(game, count = 8) {
now = until;
}
function newElement(owner) {
assert.equal(owner, self, 'every HUD element must be client-owned');
assert(players.has(owner), 'every HUD element must be owned by a known client');
const element = {
owner,
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 }); },
SetShader(shader, width, height) {
assert(!this.destroyCalls);
assert(Number.isInteger(width) && Number.isInteger(height), 'SetShader requires integer dimensions');
Object.assign(this, { shader, width, height });
},
Destroy() { this.destroyCalls++; assert.equal(this.destroyCalls, 1, 'double destruction'); this.alpha = 0; },
};
elements.push(element);
peakLive = Math.max(peakLive, elements.filter(elem => !elem.destroyCalls).length);
return element;
}
const c = vm.createContext({
self, level, NewClientHudElem: newElement, getTime: () => now,
spawnStruct: () => ({}), isDefined: v => v !== undefined && v !== null,
spawnStruct: () => ({}), isDefined: v => v !== undefined && v !== null && !v.destroyCalls,
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,
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);
if (withHud) c.register_help_command('.hud', 'Toggle core HUD');
for (const event of ['end_game', 'game_ended', 'intermission']) startThread(c.service_game_over_via, [event]);
return { c, self, level, elements, tasks, advance, source,
return { game, c, self, level, elements, tasks, advance, source,
createCore() {
assert.equal(game, 't5', 'combined fixture currently covers T5 construction');
for (const module of ['hud', 'afk'])
vm.runInContext(translate(readFileSync(new URL(`${game}/scripts/tws_${game}_${module}.gsc`, root), 'utf8')), c);
c.hud_connect(); c.afk_spawn();
},
addPlayer() { const player = { sessionstate: 'spectator' }; players.add(player); return player; },
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 live() { return elements.filter(e => !e.destroyCalls); },
get peakLive() { return peakLive; },
get visible() { return elements.filter(e => e.alpha > 0 && !e.destroyCalls); },
get textCalls() { return elements.reduce((n, e) => n + e.textCalls, 0); },
get helpCommands() {
return Array.from(self.tws_service.command_rows).filter(row => row.alpha > 0).map(row => row.text);
},
get helpDescriptions() {
return Array.from(self.tws_service.description_rows).filter(row => row.alpha > 0).map(row => row.text);
},
};
}
const snapshot = (lines = ['Alex, Sam, Jamie']) => ({ round: 82, saved_at: 0, lines });
function assertCounter(e, page, pages) {
const counter = e.state.page_counter;
if (pages === 1) assert.equal(counter, undefined, 'single-page services do not allocate a counter');
else {
assert.equal(counter.alpha, 1);
assert.equal(counter.text, `${page} / ${pages}`);
}
}
function assertTextOnly(e) {
assert(e.state.elems.every(elem => elem.shader === undefined && elem.archived === false),
'service elements are private, non-archived text with no background shaders');
}
for (const game of ['t5', 't6']) {
for (const count of [1, 4, 5, 8, 9]) check(`${game} ${count} registry entries paginate, order and dismiss`, () => {
check(`${game} installed HUD joins all nine commands in one view`, () => {
const e = engine(game, 8, true);
e.help();
assert.deepEqual(e.helpCommands,
['.help', '.hud', '.afk', '.back', '.join', '.leaders', '.b', '.d <amount|all>', '.w <amount|all>']);
assert.deepEqual(e.helpDescriptions,
['Show commands', 'Toggle core HUD', 'Protect & freeze', 'Return to play', 'Join as newcomer',
'View records', 'Bank balance', 'Deposit', 'Withdraw']);
assert.equal(e.state.help.pages, 1);
assertCounter(e, 1, 1);
assert.equal(e.live.length, 19);
assertTextOnly(e);
e.advance(12000);
assert.equal(e.live.length, 0);
const withoutHud = engine(game);
withoutHud.help();
assert(!withoutHud.state.commands.some(entry => entry.command === '.hud'));
});
check(`${game} core toggle preserves desired opacity and leaves services and other players alone`, () => {
const e = engine(game);
const track = e.c.make_core_overlay_shader(e.self, 242, 466, 156, 4, [0, 0, 0], 0.8, 10);
const bar = e.c.make_core_overlay_shader(e.self, 242, 466, 156, 4, [1, 1, 1], 0, 11);
const label = e.c.make_core_overlay_text(e.self, 6, 8, 'left', 1.4, [1, 1, 1]);
label.SetText('48:16');
const other = e.addPlayer();
const otherLabel = e.c.make_core_overlay_text(other, 6, 8, 'left', 1.4, [1, 1, 1]);
e.record(snapshot()); e.help();
const serviceAlpha = Array.from(e.state.elems, elem => elem.alpha);
const help = e.state.help, record = e.state.record;
e.c.toggle_core_hud();
assert.equal(e.self.tws_hud_hidden, true);
assert([track, bar, label].every(elem => elem.alpha === 0));
assert.equal(otherLabel.alpha, 1);
assert.equal(other.tws_hud_hidden, undefined);
e.c.set_core_alpha(e.self, bar, 1);
const afk = e.c.make_core_overlay_text(e.self, 320, 421, 'center', 1.0, [1, 1, 1]);
assert.equal(afk.alpha, 0, 'late core elements inherit the hidden state');
e.c.set_core_alpha(e.self, afk, 0);
const count = e.elements.length;
e.c.toggle_core_hud();
assert.deepEqual([track.alpha, bar.alpha, label.alpha, afk.alpha], [0.8, 1, 1, 0]);
assert.equal(label.text, '48:16', 'toggle does not rewrite displayed data');
assert.equal(e.elements.length, count);
assert.deepEqual(Array.from(e.state.elems, elem => elem.alpha), serviceAlpha);
assert.equal(e.state.help, help); assert.equal(e.state.record, record);
e.c.toggle_core_hud();
e.c.set_core_alpha(e.self, bar, 0);
e.c.set_core_alpha(e.self, afk, 1);
assert.equal(afk.alpha, 0);
e.c.toggle_core_hud();
assert.equal(bar.alpha, 0);
assert.equal(afk.alpha, 1);
label.Destroy();
e.c.toggle_core_hud();
assert(!e.self.tws_core_hud_elems.includes(label), 'toggle prunes destroyed elements');
assert.equal(label.destroyCalls, 1, 'registry never owns element destruction');
});
check(`${game} hiding before construction survives late core creation and prunes dead registrations`, () => {
const e = engine(game);
e.c.toggle_core_hud();
const old = e.c.make_core_overlay_text(e.self, 6, 8, 'left', 1.4, [1, 1, 1]);
assert.equal(old.alpha, 0);
old.Destroy();
const fresh = e.c.make_core_overlay_text(e.self, 6, 8, 'left', 1.4, [1, 1, 1]);
assert.equal(fresh.alpha, 0);
assert.equal(e.self.tws_core_hud_elems.length, 1);
e.c.toggle_core_hud();
assert.equal(fresh.alpha, 1);
assert.equal(e.level.match_start_time, undefined);
assert.equal(e.level.round_start_time, undefined);
const reconnected = e.addPlayer();
const newLabel = e.c.make_core_overlay_text(reconnected, 6, 8, 'left', 1.4, [1, 1, 1]);
assert.equal(newLabel.alpha, 1, 'a new player defaults to visible');
});
for (const count of [0, 1, 4, 8, 9]) check(`${game} ${count} registry entries 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);
const s = e.state;
assert.equal(s.help.pages, 1);
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);
assertCounter(e, 1, 1);
assert.deepEqual(e.helpCommands, Array.from(s.commands, entry => e.c.help_command_label(entry.command)));
assert.deepEqual(e.helpDescriptions, Array.from(s.commands, entry => e.c.help_description(entry)));
assert.equal(e.live.length, count * 2 + 1);
assertTextOnly(e);
e.advance(12000);
}
assert.equal(s.help, undefined);
assert.equal(e.visible.length, 0);
assert.equal(e.live.length, 0, 'idle must release even hidden elements');
});
check(`${game} repeated help reuses strings/elements and resets page one`, () => {
const e = engine(game);
check(`${game} overflow help pages reuse rows and repeated help resets expiry`, () => {
const e = engine(game, 9, true);
e.help();
assert.equal(e.state.help.pages, 2);
assert.equal(e.live.length, 20);
assertCounter(e, 1, 2);
const rows = [...e.state.command_rows, ...e.state.description_rows];
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);
assert.deepEqual(e.helpCommands, ['.x']);
assertCounter(e, 2, 2);
assert.deepEqual(Array.from(e.state.command_rows, row => row.alpha), [1, 0, 0, 0, 0, 0, 0, 0, 0]);
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.deepEqual([...e.state.command_rows, ...e.state.description_rows], rows);
e.advance(11900);
assertCounter(e, 2, 2);
e.advance(12000);
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');
assert.equal(e.live.length, 0);
});
check(`${game} help pauses record time and resumes the same page`, () => {
const e = engine(game);
@@ -169,16 +286,18 @@ for (const game of ['t5', 't6']) {
assert.equal(e.state.record.page, 1);
const remaining = e.state.record.remaining;
e.help();
const texts = e.textCalls;
e.advance(24000);
e.advance(12000);
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');
assert.equal(e.live.length, 5);
const resumedTexts = e.textCalls;
e.c.service_render(e.state);
assert.equal(e.textCalls, resumedTexts, 'subsequent ticks must reuse the rendered view');
e.advance(remaining);
assert.equal(e.state.record, undefined);
assert.equal(e.visible.length, 0);
assert.equal(e.live.length, 0);
});
check(`${game} latest record replaces pending one without disturbing help`, () => {
const e = engine(game);
@@ -190,10 +309,11 @@ for (const game of ['t5', 't6']) {
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.length, 0, 'pending records allocate no holder elements');
e.advance(9000);
assert.equal(e.state.rows[0].text, 'Newest');
assert.equal(e.state.record.remaining, 12000);
assertCounter(e, 1, 1);
e.advance(11900);
e.record(snapshot(['Newest']));
const texts = e.textCalls;
@@ -201,26 +321,40 @@ for (const game of ['t5', 't6']) {
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`, () => {
check(`${game} unavailable, empty and paged records rebuild only when their allocation changes`, () => {
const e = engine(game);
e.record(undefined);
assert.equal(e.state.unavailable.alpha, 1);
assert.equal(e.state.empty.alpha, 0);
assert.equal(e.state.record_title.alpha, 1);
assert.equal(e.state.record_title.text, 'Map record unavailable');
assert.equal(e.live.length, 4);
assertCounter(e, 1, 1);
const first = [...e.live];
e.record({ round: 0, lines: [] });
assert.equal(e.state.unavailable.alpha, 0);
assert.equal(e.state.empty.alpha, 1);
assert.equal(e.state.record_title.text, 'No recorded high round yet');
assert.deepEqual(e.live, first);
assertCounter(e, 1, 1);
e.record(snapshot(['A', 'B', 'C', 'D']));
assert.equal(e.state.empty.alpha, 0);
assert.equal(e.state.round_number.value, 82);
assert(first.every(elem => elem.destroyCalls === 1), 'pagedness changes release the old view first');
assert.equal(e.state.record_title.text, 'Map record · Round 82');
assert.equal(e.live.length, 5);
const paged = [...e.live];
assertCounter(e, 1, 2);
e.advance(12000);
assert.deepEqual(Array.from(e.state.rows, row => row.alpha), [1, 0, 0]);
assert.equal(e.state.rows[0].text, 'D');
assertCounter(e, 2, 2);
assert.deepEqual(e.live, paged, 'page changes reuse the same allocation');
e.record(snapshot(['Single page']));
assert(paged.every(elem => elem.destroyCalls === 1));
assertCounter(e, 1, 1);
assert.equal(e.state.rows[0].text, 'Single page');
assert.equal(e.live.length, 4);
assert.equal(e.peakLive, 5, 'new allocations never overlap the released view');
assertTextOnly(e);
});
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);
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);
@@ -239,41 +373,130 @@ for (const game of ['t5', 't6']) {
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`, () => {
check(`${game} rendered records cache text and never allocate on timer ticks`, () => {
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.record_title.text, 'Map record · Round 82');
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 <n|all>');
assert.equal(e.elements.length, 19, 'service allocation is bounded even when hidden');
assert(e.elements.every(elem => elem.archived === false));
assert.equal(e.c.help_command_label(e.state.commands[6].command), '.d <amount|all>');
assert.equal(e.elements.length, 4, 'record allocation includes all hidden holder rows');
assertTextOnly(e);
});
check(`${game} nominal font boxes fit the fixed panels without row collisions`, () => {
const e = engine(game);
check(`${game} service coordinates clear the watermark and stock lower-right HUD`, () => {
const e = engine(game, 8, true);
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);
assert.deepEqual([s.record_title.x, s.record_title.y, s.record_title.alignX], [633, 36, 'right']);
assert.deepEqual(Array.from(s.rows, row => [row.x, row.y, row.alignX]),
[[633, 60, 'right'], [633, 78, 'right'], [633, 96, 'right']]);
const records = [...s.elems];
e.help();
assert.deepEqual([s.help_title.x, s.help_title.y], [432, 54]);
for (const [i, row] of s.command_rows.entries()) {
assert.equal(row.x, 432); assert.equal(row.y, 80 + i * 24);
assert.equal(s.description_rows[i].x, 538);
assert.equal(s.description_rows[i].y, row.y);
}
for (const [width, height] of [[1920, 1080], [1280, 720], [1600, 1200], [3440, 1440]]) {
const project = elem => ({ x: elem.x * width / 640, y: elem.y * height / 480 });
for (const elem of [...records, ...s.elems]) {
assert.equal(elem.horzAlign, 'fullscreen');
assert.equal(elem.vertAlign, 'fullscreen');
const point = project(elem);
assert(point.x > width / 2 && point.x < width);
assert(point.y < height * 0.6, 'leave scores and ammo clear');
}
assert.equal(width - project(records[0]).x, 7 * width / 640);
if (width === 1920) assert(project(records[0]).y > 27, 'clear the reference Plutonium tag');
}
});
check(`${game} new and legacy shader helpers retain integer dimensions`, () => {
const e = engine(game);
// Native GSC max returns VAR_FLOAT even for integral values. Boxed
// numbers retain that distinction here; the explicit int() unboxes it.
e.c.max = (...args) => new Number(Math.max(...args));
const legacy = e.c.make_panel_shader(e.self, 0, 0, 220, 100,
e.level.hud_c_panel, e.level.hud_panel_alpha, 0);
assert.equal(legacy.width, 165);
assert.equal(legacy.alpha, 0.72);
assert.deepEqual(Array.from(legacy.color), [0.07, 0.10, 0.08]);
const overlay = e.c.make_overlay_shader(e.self, 242, 466, 156, 4, [1, 1, 1], 0.8, 10);
assert.deepEqual([overlay.x, overlay.y, overlay.width, overlay.height], [242, 466, 156, 4]);
for (const [design, panelWidth, overlayWidth] of [[200, 150, 200], [40, 30, 40], [65.9, 49, 65], [0, 1, 1], [0.1, 1, 1]]) {
assert.equal(e.c.make_panel_width(design), panelWidth);
assert.equal(e.c.make_overlay_width(design), overlayWidth);
legacy.SetShader('white', e.c.make_panel_width(design), 4);
overlay.SetShader('white', e.c.make_overlay_width(design), 4);
}
assert.throws(() => overlay.SetShader('white', e.c.max(1, 156), 4), /requires integer dimensions/);
const oldCore = e.c.make_core_text(e.self, 10, 10, 'left', 1.1, [1, 1, 1]);
const oldShader = e.c.make_core_shader(e.self, 10, 80, 200, 4, [1, 1, 1], 0.5, 10);
e.c.toggle_core_hud();
assert.equal(oldCore.alpha, 0); assert.equal(oldShader.alpha, 0);
e.c.toggle_core_hud();
assert.equal(oldCore.alpha, 1); assert.equal(oldShader.alpha, 0.5);
});
check(`${game} explicit service rows fit nominal font bounds without newline dependence`, () => {
const e = engine(game, 8, true);
e.record(snapshot(['One', 'Two', 'Three']));
const height = elem => Math.trunc(12 * elem.fontScale);
let s = e.state;
assert(s.record_title.y + height(s.record_title) <= 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);
const rows = [...s.command_rows, ...s.description_rows];
assert.equal(new Set(rows).size, 18);
assert.equal(new Set(s.command_rows.map(row => row.y)).size, 9);
assert(rows.every(row => row.alpha === 1 && !/[\r\n]/.test(row.text)));
for (let i = 0; i < s.command_rows.length; i++) {
const command = s.command_rows[i], description = s.description_rows[i];
assert.equal(command.y, description.y);
if (i > 0) assert(s.command_rows[i - 1].y + height(s.command_rows[i - 1]) < command.y);
assert(command.y + height(command) < 320);
}
assert(e.elements.filter(elem => elem.fontScale !== undefined).every(elem => elem.fontScale >= 1.0));
assert.equal(e.c.make_text_elem(e.self, 0, 0, 'left', 0.9, [1, 1, 1]).fontScale, 1.0);
assert.equal(e.c.make_overlay_text(e.self, 0, 0, 'left', 0.9, [1, 1, 1]).fontScale, 1.0);
assert.equal(e.c.make_overlay_text(e.self, 0, 0, 'left', 1.6, [1, 1, 1]).fontScale, 1.6);
});
check(`${game} help rebuilds after records, caching each new view without allocation overlap`, () => {
const e = engine(game, 8, true);
e.help();
const rows = [...e.state.command_rows, ...e.state.description_rows];
const calls = e.textCalls;
e.help(); e.advance(11000);
assert.equal(e.textCalls, calls);
e.record(snapshot()); e.advance(1000);
assert(rows.every(row => row.destroyCalls === 1));
assert.equal(e.live.length, 4);
e.help();
assert.equal(e.live.length, 19);
assert(e.state.command_rows.every(row => !rows.includes(row)));
assert.equal(e.peakLive, 19);
const recreatedCalls = e.textCalls;
e.help();
assert.equal(e.textCalls, recreatedCalls);
e.event('disconnect');
assert(e.elements.every(row => row.destroyCalls === 1));
});
for (const event of ['disconnect', 'end_game', 'game_ended', 'intermission'])
check(`${game} ${event} after idle cannot double-destroy or recreate a released view`, () => {
const e = engine(game);
e.record(snapshot()); e.advance(12000);
assert.equal(e.live.length, 0);
const allocated = e.elements.length;
if (event === 'disconnect') e.event(event); else e.end(event);
assert(e.state.closed);
e.advance(60000);
if (event !== 'disconnect') { e.help(); e.record(snapshot()); e.event('disconnect'); }
assert.equal(e.elements.length, allocated);
assert(e.elements.every(elem => elem.destroyCalls === 1));
});
check(`${game} script entrypoints and common references resolve`, () => {
const functions = new Set([...eSource(game).matchAll(/^(\w+)\([^\n]*\)\s*\n\{/gm)].map(m => m[1]));
@@ -295,8 +518,67 @@ for (const game of ['t5', 't6']) {
});
}
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'));
check('T5 service transitions preserve actual core element identities, styles, data and visibility', () => {
const e = engine('t5', 8, true);
e.createCore();
const core = Array.from(e.self.tws_core_hud_elems);
assert.equal(core.length, 12, 'eleven core elements plus one optional AFK indicator');
assert.equal(e.live.length, 12);
e.self.tws_hud_count.SetValue(6);
e.self.tws_hud_health.SetValue(100);
e.self.tws_hud_maxhealth.SetValue(100);
e.c.set_core_alpha(e.self, e.self.tws_hud_bar, 1);
const state = () => core.map(elem => ({ alpha: elem.alpha, text: elem.text, value: elem.value,
x: elem.x, y: elem.y, width: elem.width, height: elem.height, shader: elem.shader,
color: elem.color, fontScale: elem.fontScale, alignX: elem.alignX,
textCalls: elem.textCalls, valueCalls: elem.valueCalls, destroyCalls: elem.destroyCalls }));
const initial = state();
e.advance(3000);
e.record(snapshot(['Jeff', 'Lokester', 'Moniialx']));
const controller = e.state, tasks = e.tasks.length;
assert.equal(e.live.length, 16);
assert.deepEqual(state(), initial);
e.advance(12000);
assert.equal(e.state, controller);
assert.equal(e.state.closed, false, 'idle releases the view, not its controller');
assert.equal(e.state.elems.length, 0);
assert.equal(e.live.length, 12);
assert.deepEqual(state(), initial);
for (let i = 0; i < 4; i++) {
e.record(snapshot(['One', 'Two', 'Three', 'Four'])); e.advance(15000); e.help();
assert.equal(e.live.length, 31, '12 core/AFK plus 19 help elements; this is not an engine pool-limit assertion');
assert.equal(e.live.filter(elem => elem.archived === false).length, 19);
e.c.toggle_core_hud();
assert(core.every(elem => elem.alpha === 0));
const allocations = e.elements.length;
e.record(snapshot(['Latest']));
assert.equal(e.elements.length, allocations, 'a pending record must not allocate another view');
e.advance(12000);
assert.equal(e.state.rows[0].text, 'Latest');
assert.equal(e.state.record.remaining, 12000);
assert.equal(e.live.length, 16);
assert(core.every(elem => elem.alpha === 0 && elem.destroyCalls === 0));
e.c.toggle_core_hud();
assert.deepEqual(state(), initial);
e.advance(12000);
assert.equal(e.live.length, 12);
assert.equal(e.state.elems.length, 0);
assert.equal(e.tasks.length, tasks, 'reopening reuses the controller and cleanup watchers');
assert.deepEqual(state(), initial);
}
assert.equal(e.peakLive, 31);
assert.deepEqual(Array.from(e.self.tws_core_hud_elems), core);
e.event('disconnect');
assert.equal(e.live.length, 0);
assert(e.elements.every(elem => elem.destroyCalls === 1));
});
check('paired service requests, rendering and lifecycle remain identical', () => {
const sections = game => {
const source = eSource(game);
const service = source.slice(source.indexOf('show_help()\n{'), source.indexOf('on_player_connect_dispatch()\n{'));
assert(service.length > 500, 'parity comparison must include actual service functions');
return service;
};
assert.deepEqual(sections('t5'), sections('t6'));
});
console.log(`PASS ${checks} service HUD fixture/static checks (both games). GSC compilation and Plutonium rendering remain separate.`);
+6 -6
View File
@@ -1,16 +1,16 @@
{
"tws_t5_afk.gsc": "1.0.1",
"tws_t5_afk.gsc": "1.2.0",
"tws_t5_bank.gsc": "2.0.3",
"tws_t5_common.gsc": "1.2.0",
"tws_t5_hud.gsc": "1.2.0",
"tws_t5_common.gsc": "1.4.0",
"tws_t5_hud.gsc": "1.4.0",
"tws_t5_join.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.1",
"tws_t6_afk.gsc": "1.2.0",
"tws_t6_bank.gsc": "2.0.3",
"tws_t6_common.gsc": "1.2.0",
"tws_t6_hud.gsc": "1.2.0",
"tws_t6_common.gsc": "1.4.0",
"tws_t6_hud.gsc": "1.4.0",
"tws_t6_join.gsc": "1.0.1",
"tws_t6_leaderboard.gsc": "1.1.0",
"tws_t6_lock.gsc": "1.0.0",