Add fixed core HUD and shared service window for T5 and T6
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)`
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+399
-58
@@ -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,42 +28,51 @@ 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;
|
||||
|
||||
// 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_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_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;
|
||||
|
||||
// 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_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);
|
||||
|
||||
// numbers fade in below this fraction of max hp
|
||||
level.hud_health_threshold = 1.0;
|
||||
|
||||
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");
|
||||
|
||||
+108
-167
@@ -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,83 +247,43 @@ 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;
|
||||
|
||||
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)
|
||||
@@ -340,30 +294,17 @@ hud_health_display()
|
||||
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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+394
-58
@@ -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,42 +25,51 @@ 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;
|
||||
|
||||
// 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_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_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;
|
||||
|
||||
// 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_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);
|
||||
|
||||
// numbers fade in below this fraction of max hp
|
||||
level.hud_health_threshold = 1.0;
|
||||
|
||||
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");
|
||||
|
||||
+95
-154
@@ -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,80 +236,43 @@ 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;
|
||||
|
||||
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)
|
||||
@@ -329,24 +283,11 @@ hud_health_display()
|
||||
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)
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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).`);
|
||||
@@ -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.`);
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user