Add fixed core HUD and shared service window for T5 and T6

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