Add scaling Alien Grunt boss waves and native simulation; publish Web release

This commit is contained in:
enne2
2026-10-15 15:08:52 +02:00
parent 5761e160c1
commit 2606fc5cc2
44 changed files with 1464 additions and 9 deletions
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cdts6iktprn6i"
path="res://.godot/imported/ag_alert1.wav-acffe378ab4d6384a1a49fa2bd60bbf2.sample"
[deps]
source_file="res://assets/audio/agrunt/ag_alert1.wav"
dest_files=["res://.godot/imported/ag_alert1.wav-acffe378ab4d6384a1a49fa2bd60bbf2.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://dgr42fotc24u1"
path="res://.godot/imported/ag_attack1.wav-fa83036792842a5b3d3a66290dac5208.sample"
[deps]
source_file="res://assets/audio/agrunt/ag_attack1.wav"
dest_files=["res://.godot/imported/ag_attack1.wav-fa83036792842a5b3d3a66290dac5208.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c8kiggo0486sf"
path="res://.godot/imported/ag_die1.wav-f92fb6a0c3738ed34e382ea4353b4a9d.sample"
[deps]
source_file="res://assets/audio/agrunt/ag_die1.wav"
dest_files=["res://.godot/imported/ag_die1.wav-f92fb6a0c3738ed34e382ea4353b4a9d.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://dmo48gk3xbvg3"
path="res://.godot/imported/ag_fire1.wav-2f950cd03eb69f34e7fd7c2a8039b9c5.sample"
[deps]
source_file="res://assets/audio/agrunt/ag_fire1.wav"
dest_files=["res://.godot/imported/ag_fire1.wav-2f950cd03eb69f34e7fd7c2a8039b9c5.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cldtgo07ex63i"
path="res://.godot/imported/ag_idle1.wav-d9574faf8f4b389f09cac1865389c210.sample"
[deps]
source_file="res://assets/audio/agrunt/ag_idle1.wav"
dest_files=["res://.godot/imported/ag_idle1.wav-d9574faf8f4b389f09cac1865389c210.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://d0fa5d4u8jg7o"
path="res://.godot/imported/ag_pain1.wav-1a2fea6061f7be683023ff0a06ce5be3.sample"
[deps]
source_file="res://assets/audio/agrunt/ag_pain1.wav"
dest_files=["res://.godot/imported/ag_pain1.wav-1a2fea6061f7be683023ff0a06ce5be3.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://vv6j6hku4ojs"
path="res://.godot/imported/port_suckin1.wav-c7fd1d2d69066020efaffa27f820fa58.sample"
[deps]
source_file="res://assets/audio/ambience/port_suckin1.wav"
dest_files=["res://.godot/imported/port_suckin1.wav-c7fd1d2d69066020efaffa27f820fa58.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://br5o0bbodoumv"
path="res://.godot/imported/port_suckout1.wav-9db293b668ab7745c877676f6183cc29.sample"
[deps]
source_file="res://assets/audio/ambience/port_suckout1.wav"
dest_files=["res://.godot/imported/port_suckout1.wav-9db293b668ab7745c877676f6183cc29.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
[remap]
importer="goldsrc.mdl"
importer_version=2
type="PackedScene"
uid="uid://dgpthnqdl2cav"
path="res://.godot/imported/agrunt_textured.mdl-6e6e948c8e5921446dd409aeee04ef1f.scn"
[deps]
source_file="res://assets/boss/agrunt_textured.mdl"
dest_files=["res://.godot/imported/agrunt_textured.mdl-6e6e948c8e5921446dd409aeee04ef1f.scn"]
[params]
scale_factor=0.025
Binary file not shown.
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
[remap]
importer="goldsrc.mdl"
importer_version=2
type="PackedScene"
uid="uid://cpmr4qhd4t7me"
path="res://.godot/imported/hornet_textured.mdl-b1c3fc2d788c210af5f8b7c13bbc4cac.scn"
[deps]
source_file="res://assets/boss/hornet_textured.mdl"
dest_files=["res://.godot/imported/hornet_textured.mdl-b1c3fc2d788c210af5f8b7c13bbc4cac.scn"]
[params]
scale_factor=0.025
Binary file not shown.
+134
View File
@@ -0,0 +1,134 @@
[
{
"sources": [
{
"path": "models/agrunt.mdl",
"sha256": "ec415074fa6a5ed23e47b1370d03b052ffafa735de328233c42db33e62bcbc27"
},
{
"path": "models/agruntt.mdl",
"sha256": "b63ebbde5eadf176235057c7746339fd7c040e0cd05f209cee0ce8606e3d7ab2"
}
],
"destination": "assets/boss/agrunt_textured.mdl",
"sha256": "c780b5a7f71f997d59da72bb57d2a732eae061d8cf6a179ebcb85408465ceaf7",
"animations": [
"bigflinch",
"diebackward",
"dieforward",
"diegut",
"diehead",
"diesimple",
"hornetattack",
"idle1",
"larmflinch",
"llegflinch",
"longshoot",
"mattack2",
"mattack3",
"quickshoot",
"rarmflinch",
"rlegflinch",
"run",
"smallflinch",
"threat",
"turnl",
"turnr",
"victoryeat",
"victorysquat",
"victorysquat_2",
"walk"
],
"omitted_sequences": [
{
"index": 17,
"label": "attack3",
"external_group": 1
},
{
"index": 18,
"label": "attack3",
"external_group": 1
},
{
"index": 27,
"label": "float",
"external_group": 1
},
{
"index": 28,
"label": "scare",
"external_group": 1
},
{
"index": 29,
"label": "bigopen",
"external_group": 1
},
{
"index": 30,
"label": "smashrail",
"external_group": 1
},
{
"index": 31,
"label": "landhard",
"external_group": 1
}
]
},
{
"sources": [
{
"path": "models/hornet.mdl",
"sha256": "05297b1015e00543fd1c6da4ddb52fd1868bfb3fadf10fe8fe842cbb59a70990"
}
],
"destination": "assets/boss/hornet_textured.mdl",
"sha256": "12e71d44d3b15158140bcc2bf84e17d17f700e45b421528a699c7d95373a3ce6",
"animations": [
"idle"
],
"omitted_sequences": []
},
{
"source": "sound/agrunt/ag_alert1.wav",
"destination": "assets/audio/agrunt/ag_alert1.wav",
"sha256": "e7db247b49bb4f2e35039b6547226328217781b4601fe27eaed62847261ab901"
},
{
"source": "sound/agrunt/ag_attack1.wav",
"destination": "assets/audio/agrunt/ag_attack1.wav",
"sha256": "bcf3445e450130c0c6bc04d57e278a41d53204e12d7e75ed94f6b8c35a194c01"
},
{
"source": "sound/agrunt/ag_fire1.wav",
"destination": "assets/audio/agrunt/ag_fire1.wav",
"sha256": "783257df249a9393809b4dd12ef00b26ef53b72b2567c6f71391bf80b4b8c0fd"
},
{
"source": "sound/agrunt/ag_idle1.wav",
"destination": "assets/audio/agrunt/ag_idle1.wav",
"sha256": "99ba60b58c79a36674e9e73cc27c29935ad27daa8b55479dab76fcbf5a6e5a28"
},
{
"source": "sound/agrunt/ag_pain1.wav",
"destination": "assets/audio/agrunt/ag_pain1.wav",
"sha256": "4bdcfa8c6092eb9c269e28066bb04e4858191ea38f88a545a34b67e5fe701fc1"
},
{
"source": "sound/agrunt/ag_die1.wav",
"destination": "assets/audio/agrunt/ag_die1.wav",
"sha256": "a89a59e47cf15983089f04c3e7b56a72c5f35a4b69a69770b34db58512a61b0f"
},
{
"source": "sound/ambience/port_suckin1.wav",
"destination": "assets/audio/ambience/port_suckin1.wav",
"sha256": "10a0114475ed645d477cda7912200a391f410388d8fff48980d89b9abc18c787"
},
{
"source": "sound/ambience/port_suckout1.wav",
"destination": "assets/audio/ambience/port_suckout1.wav",
"sha256": "1acbd24d45534bc231933cf53dbec3d3822cd34330ab7a283a9e5ff6a22c2ef9"
}
]
+152
View File
@@ -0,0 +1,152 @@
# Boss Alien Grunt — ondate dedicate e simulazione nativa
Pubblicato su https://enne2.net/pub/zombie-fps/ nella release **`86c32ea9f5a4470e`**.
Boss e asset sono inclusi nella versione Web; la simulazione diretta resta nativa.
## Regole delle ondate
- **10, 20, 30, ...**: un solo Alien Grunt sostituisce l'intera ondata ordinaria.
Nessuno zombie aggiuntivo, nessuna seconda fase o sotto-ondata.
- Dopo la morte del boss, attesa standard di 3 secondi e ripresa degli zombie
nell'ondata successiva (11, 21, ...).
- Il boss conta come vivo fin dall'apertura del portale: il preavviso non viene
scambiato per un'ondata vuota. Morte, premio e transizione sono emessi una sola volta.
- Il teletrasporto cerca una posizione libera dagli arredi e collegata alla
navigazione, distante almeno 5 metri dal giocatore. Il preavviso dura **1,5 s**:
anelli, particelle CPU, luce verde/ciano e suoni originali di teletrasporto.
Durante il preavviso il boss è invisibile e non colpibile.
- Barra della vita dedicata, grado e indicatore ARRIVO nell'HUD. Leggero bagliore
Xen senza ombre per rendere il boss leggibile dopo lo spegnimento del portale.
## Progressione e combattimento
Il grado è `ondata / 10`; salute e danni aumentano senza cambiare la geometria del boss.
Velocità e cadenza hanno limiti per lasciare spazio alla schivata.
| Ondata | Grado | Vita | Danno pugno | Danno/proiettile | Velocità m/s | Intervallo attacchi | Premio |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 10 | 1 | 800 | 28 | 8 | 2,00 | 2,80 s | 1000 |
| 20 | 2 | 1200 | 34 | 10 | 2,12 | 2,62 s | 2000 |
| 30 | 3 | 1600 | 40 | 12 | 2,24 | 2,44 s | 3000 |
Formule (grado `g`):
- HP: `800 + 400 × (g − 1)`.
- Pugno: `28 + 6 × (g − 1)`; proiettile: `8 + 2 × (g − 1)`.
- Velocità: `min(3.4, 2 + 0.12 × (g − 1))`.
- Intervallo: `max(1.3, 2.8 − 0.18 × (g − 1))` secondi.
- Premio: `1000 × g`, indipendente dal colpo finale alla testa.
Il boss insegue usando la stessa mappa A* degli zombie, con capsula raggio 0,45 m e
altezza 2,4 m. Pugno ravvicinato ritardato di 0,45 s e controllato per distanza e
occlusione. A distanza: animazione di preavviso, poi tre hornet originali separati da
0,18 s. Velocità 8 m/s, traiettoria rettilinea **schivabile**, non hitscan e non a ricerca.
Un raggio continuo fra le posizioni fisiche intercetta giocatore e pareti: i proiettili
non attraversano gli oggetti. Vita massima 3 s; morte del boss annulla i colpi pendenti
ed elimina i suoi proiettili ancora in volo.
Fucile e granata usano la normale hitbox/gruppo `live_zombies`. Colpo alla testa sopra
1,95 m: moltiplicatore **1,5** (non quello dello zombie ordinario). Durante il
teletrasporto i danni sono ignorati. Alla morte il corpo resta, senza collisioni né
conteggio fra i nemici vivi.
## Prova nativa immediata
Dalla radice del progetto, senza attendere nove ondate:
```bash
# Primo boss, salute del giocatore normale
tools/run_boss_simulation.sh 10
# Primo boss, giocatore invulnerabile (modalità usata per la prova interattiva)
tools/run_boss_simulation.sh 10 --boss-god
# Boss successivi, già potenziati
tools/run_boss_simulation.sh 20
tools/run_boss_simulation.sh 30 --boss-god
```
Il launcher usa Godot 4.7.1 installato e il renderer Compatibility. Un'altra posizione
si può indicare con `GODOT_BIN=/percorso/Godot`. Equivalente diretto:
```bash
"$HOME/.local/opt/godot-4.7.1/Godot_v4.7.1-stable_linux.x86_64" \
--path . --rendering-method gl_compatibility -- --boss-wave=10 --boss-god
```
**Gli argomenti di gioco vanno dopo `--`.** Il numero deve essere un multiplo positivo
di dieci; valori non validi terminano con codice 2. Questi argomenti sono ignorati
sul Web: la simulazione è esclusivamente nativa.
Controlli invariati: WASD, mouse, sinistro fucile, destro granata, R ricarica,
Spazio salto, Esc cursore. In modalità invulnerabile munizioni e ricariche restano
reali, semplicemente non si perdono HP.
- Parte direttamente il teletrasporto, senza menu o nome.
- La simulazione **non legge né scrive il profilo**, non aggiorna il miglior punteggio
e disattiva la classifica anche se viene passato `--api=`.
- Dopo la vittoria resta nella **singola ondata** di prova; R oppure RIPROVA riavviano
lo stesso grado. Dopo la morte valgono R/RICOMINCIA già presenti nel gioco.
- Per una prova senza invulnerabilità, avviare il comando senza `--boss-god`.
- La proprietà `player.invulnerable` è falsa nel gioco normale; `--boss-god` da solo,
senza una simulazione, non la abilita.
## Asset originali e compatibilità
`tools/extract_boss_assets.py` legge l'installazione locale di Half-Life senza modificarla:
- `agrunt.mdl` + le 28 texture del companion `agruntt.mdl` incorporato.
- `hornet.mdl`, con texture già presenti.
- Voci `sound/agrunt/ag_{alert,attack,fire,idle,pain,die}1.wav`.
- `sound/ambience/port_suckin1.wav` e `port_suckout1.wav`.
Gli MDL derivati sono in `assets/boss/`, con SHA256 sorgenti/derivati e lista delle
animazioni in `source_manifest.json`. Sono conservate **25 sequenze autonome del
sequence-group zero** del grunt, incluse idle/walk/run, melee, hornetattack e morti.
Le 7 sequenze del gruppo esterno `agrunt01.mdl` (cinematiche e doppioni attack3) sono
escluse esplicitamente, non importate come clip vuote.
`tools/export_boss_assets.gd` genera `agrunt_textured.scn` e `hornet_textured.scn`,
verificando texture e animazioni. In alternativa il normale
`tools/export_web_assets.gd` ora include anche i due modelli. Runtime nativo/Web usa
solo **SCN, WAV e primitive Godot**, nessuna dipendenza dal plugin MDL nativo.
Nessun effetto richiede Forward+ o particelle GPU. La pipeline Web include i due
modelli; i 50 controlli sono passati anche nel progetto di staging senza GDExtension.
Il menu della release pubblicata è stato verificato nel browser senza avviare
partite di prova sulla classifica pubblica.
```bash
python3 tools/extract_boss_assets.py
"$HOME/.local/opt/godot-4.7.1/Godot_v4.7.1-stable_linux.x86_64" --headless --path . --import
"$HOME/.local/opt/godot-4.7.1/Godot_v4.7.1-stable_linux.x86_64" --headless --path . \
--script res://tools/export_boss_assets.gd
```
## Verifiche
```bash
G="$HOME/.local/opt/godot-4.7.1/Godot_v4.7.1-stable_linux.x86_64"
"$G" --headless --path . --script res://tools/test_boss.gd
DISPLAY=:0 "$G" --path . --display-driver x11 --rendering-method gl_compatibility \
--script res://tools/test_boss.gd
DISPLAY=:0 "$G" --path . --display-driver x11 --rendering-method gl_compatibility \
--script res://tools/shot_boss.gd
```
**50 controlli**: selezione delle ondate, progressione e limiti, sedici estrazioni del
portale, completamento reale dell'ondata 9 con 22 zombie e transizione 9→10→11,
boss unico, teletrasporto/invulnerabilità iniziale, texture/animazioni, proiettili e
ostacoli, corpo a corpo, navigazione fisica attorno a entrambe le scrivanie/sedie/bidone,
fucile/granata, barra HP frazionaria, morte/premio singoli, annullamento raffiche,
simulazione di grado 2 con vittoria/morte e profilo persistente byte-identico.
Passate inoltre le 15 suite Godot preesistenti e i 42 controlli dell'API locale.
Le prove GUI `test_start_screen` richiedono il display reale **senza `--touch`**;
`test_touch_controls` viene eseguito separatamente con `--touch`.
Restano gli avvisi di risorse non liberate alla chiusura già presenti nelle prove
con gli asset GoldSrc; nessun errore di script durante il combattimento.
Screenshot: `/tmp/zombie-boss-{portal,arrival,combat}.png`.
Riferimento originale: https://github.com/ValveSoftware/halflife/blob/master/dlls/agrunt.cpp
(il boss potenziato e le regole di ondata sono specifici di questo progetto).
+5 -3
View File
@@ -50,9 +50,11 @@ Web and explosion WAVs; provenance `assets/half_life_manifest.json`, extraction
## Growing waves
Wave size: **4 + 2 × wave** (6, 8, 10, ...), no old cap of 24. Next wave three seconds
after all enemies die and spawning completes. Scheduling guard prevents duplicate
wave timers. Corpses remain.
Ordinary wave size: **4 + 2 × wave** (6, 8, 10, ...), no old cap of 24. Every tenth
wave (10, 20, 30, ...) is instead **one Alien Grunt boss**, with no ordinary zombies;
see `docs/BOSS.md` for scaling, teleport arrival and the native-only test sandbox.
Next wave three seconds after all enemies die and spawning completes. Scheduling
guard prevents duplicate wave timers. Corpses remain.
## Tests and visual verification
+20
View File
@@ -1,5 +1,25 @@
# Web build and compressed deployment
## Alien Grunt boss release: 86c32ea9f5a4470e
Every tenth wave is a single teleporting Alien Grunt, with progressively increasing
stats, original model/animations/voices and hornet projectiles; see `docs/BOSS.md`.
Native-only sandbox launcher `tools/run_boss_simulation.sh` is included in the source
repository, not exposed on the Web; no simulation scores/profile writes.
- PCK 6,623,088 bytes (gzip 3,421,794); total gzip payload 13,570,852 bytes.
- All 14 uploaded file hashes verified; HTTPS-decoded PCK byte-identical to local.
- Serial staging import; 50/50 boss checks repeated in GDExtension-free staging.
- Native scenes validated: agrunt 33 textured surfaces, hornet 1; unchanged weapon
and zombie scenes retain their established export rather than being repacked.
- Upload interrupted before completion, then resumed in the inactive new directory;
the previous live index was untouched until all files were verified.
- Atomic root swap, backup `/home/debian/zombie-fps-backups/index-before-boss-86c32ea9f5a4470e.html`.
- Browser boot/menu verified against the new release; no production game submissions.
- Root/previous PCK HTTP 200, WASM MIME/immutable/Vary intact, scores API healthy.
- No nginx/API/DB changes or cleanup; previous `add7baaef2b022ef` and older
`a961c96175bc644e`, `209fa6b154fea8df` retained for rollback (four directories).
## Contact grenade / original Half-Life fireball: add7baaef2b022ef
First-impact detonation (floor, props, walls, enemies and glass), no old timed fuse;
+185
View File
@@ -0,0 +1,185 @@
extends "res://scripts/zombie.gd"
## One Alien Grunt replaces every tenth wave. Uses the shared office navigation.
const Hornet = preload("res://scripts/boss_hornet.gd")
var rank := 1
var max_hp := 800.0
var melee_damage := 28.0
var ranged_damage := 8.0
var attack_interval := 2.8
var reward := 1000
var attack_lock := 0.0
var pain_cooldown := 0.0
var model: Node3D
static func stats_for_wave(number: int) -> Dictionary:
var grade := maxi(1, floori(number / 10.0))
return {
"rank": grade, "hp": 800.0 + 400.0 * (grade - 1),
"speed": minf(3.4, 2.0 + 0.12 * (grade - 1)),
"melee": 28.0 + 6.0 * (grade - 1), "ranged": 8.0 + 2.0 * (grade - 1),
"interval": maxf(1.3, 2.8 - 0.18 * (grade - 1)), "reward": grade * 1000,
}
func configure(number: int) -> void:
var stats := stats_for_wave(number)
rank = stats.rank
max_hp = stats.hp
hp = max_hp
speed = stats.speed
melee_damage = stats.melee
ranged_damage = stats.ranged
attack_interval = stats.interval
reward = stats.reward
func _ready() -> void:
name = "AlienGruntBoss"
add_to_group("live_zombies") # bullets, blast, target-counting use the same interface
add_to_group("bosses")
collision_layer = 0 # no hits or obstruction during materialization
collision_mask = 1 | 4
var shape := CollisionShape3D.new()
var cap := CapsuleShape3D.new()
cap.radius = 0.45
cap.height = 2.4
shape.shape = cap
shape.position.y = 1.2
add_child(shape)
model = preload("res://assets/boss/agrunt_textured.scn").instantiate()
model.scale = Vector3.ONE * 1.1
add_child(model)
anim = model.get_node("AnimationPlayer")
for mesh in model.find_children("*", "MeshInstance3D", true, false):
mesh.layers = 2
for area in model.find_children("*", "Area3D", true, false):
area.collision_layer = 0
area.collision_mask = 0
model.hide()
# Residual Xen glow keeps the boss readable after the portal flash has faded.
var glow := OmniLight3D.new()
glow.name = "XenGlow"
glow.light_color = Color(0.5, 1.0, 0.68)
glow.light_energy = 0.65
glow.light_specular = 0.3
glow.omni_range = 3.5
glow.shadow_enabled = false
glow.position = Vector3(0, 1.8, -1.1)
glow.visible = false
add_child(glow)
cooldown = 0.8
_repath = 0
func materialize() -> void:
if dead or not is_instance_valid(player) or player.hp <= 0:
return
model.show()
get_node("XenGlow").show()
collision_layer = 4
entering = false
anim.play("threat", 0.1)
attack_lock = 0.7
Audio.play(self, "agrunt/ag_alert1.wav", true, -8)
func _physics_process(dt: float) -> void:
if dead or entering or not is_instance_valid(player) or player.hp <= 0:
return
cooldown -= dt
attack_lock -= dt
pain_cooldown -= dt
voice_timer -= dt
if voice_timer <= 0:
voice_timer = 7.0
Audio.play(self, "agrunt/ag_idle1.wav", true, -14)
var delta := player.global_position - global_position
delta.y = 0
var distance := delta.length()
if distance > 0.01:
rotation.y = atan2(-delta.x, -delta.z)
var motion := Vector3.ZERO
if attack_lock <= 0:
if distance <= 2.2 and _attack_clear() and cooldown <= 0:
_start_melee()
elif distance > 2.2 and distance < 14 and cooldown <= 0 and _ranged_clear():
_start_burst()
elif distance > 2.0 or not _attack_clear():
motion = _pursuit_direction(dt) * speed
if motion.length_squared() > 0.01:
rotation.y = atan2(-motion.x, -motion.z)
if anim.current_animation != "walk":
anim.play("walk", 0.12)
elif anim.current_animation != "idle1":
anim.play("idle1", 0.12)
velocity.x = motion.x
velocity.z = motion.z
velocity.y = -1.0 if is_on_floor() else velocity.y - 20 * dt
move_and_slide()
func _ranged_clear() -> bool:
var from := global_position + Vector3.UP * 1.5
var to := player.global_position + Vector3.UP * 1.0
var query := PhysicsRayQueryParameters3D.create(from, to, 1)
return get_world_3d().direct_space_state.intersect_ray(query).is_empty()
func _start_melee() -> void:
cooldown = attack_interval
attack_lock = 0.85
anim.play("mattack2", 0.1)
Audio.play(self, "agrunt/ag_attack1.wav", true, -9)
get_tree().create_timer(0.45).timeout.connect(_attack)
func _attack() -> void:
if not dead and not entering and is_instance_valid(player) and player.hp > 0 \
and global_position.distance_to(player.global_position) < 2.7 and _attack_clear():
player.take_damage(melee_damage)
func _start_burst() -> void:
cooldown = attack_interval
attack_lock = 0.9
anim.play("hornetattack", 0.08)
# Telegraph the attack, then fire three visible dodgeable hornets.
for i in range(3):
get_tree().create_timer(0.3 + i * 0.18).timeout.connect(_fire_hornet)
func _fire_hornet() -> void:
if dead or entering or not is_instance_valid(player) or player.hp <= 0:
return
var from := global_position + Vector3.UP * 1.5
var direction := (player.global_position + Vector3.UP - from).normalized()
var projectile := Hornet.new()
projectile.player = player
projectile.shooter = self
projectile.damage = ranged_damage
projectile.direction = direction
get_parent().add_child(projectile)
# Begin at the chest, not beyond it: an offset could skip a nearby thin wall.
projectile.global_position = from
projectile.look_at(projectile.global_position + direction)
Audio.play(self, "agrunt/ag_fire1.wav", true, -13)
func take_damage(amount: float, hit_y: float) -> void:
if dead or entering or amount <= 0:
return
var headshot := hit_y - global_position.y > 1.95
hp = maxf(0, hp - amount * (1.5 if headshot else 1.0))
if hp > 0:
if pain_cooldown <= 0:
pain_cooldown = 0.7
Audio.play(self, "agrunt/ag_pain1.wav", true, -12)
return
dead = true
get_node("XenGlow").hide()
remove_from_group("live_zombies")
remove_from_group("bosses")
add_to_group("boss_corpses")
velocity = Vector3.ZERO
collision_layer = 0
collision_mask = 0
for speaker in get_children():
if speaker is AudioStreamPlayer3D:
speaker.stop()
speaker.queue_free()
for projectile in get_tree().get_nodes_in_group("boss_projectiles"):
if projectile.shooter == self:
projectile.queue_free()
Audio.play(self, "agrunt/ag_die1.wav", true, -8)
anim.play("diehead" if headshot else "diesimple", 0.1)
killed.emit(headshot)
+1
View File
@@ -0,0 +1 @@
uid://dgjgrwojm7qea
+55
View File
@@ -0,0 +1,55 @@
extends Node3D
## Visible, dodgeable Alien Grunt projectile. Swept ray prevents tunnelling.
const SPEED := 8.0
const LIFETIME := 3.0
var direction := Vector3.FORWARD
var damage := 8.0
var shooter: CharacterBody3D
var player: CharacterBody3D
var age := 0.0
var spent := false
func _ready() -> void:
add_to_group("boss_projectiles")
var model: Node3D = preload("res://assets/boss/hornet_textured.scn").instantiate()
add_child(model)
for area in model.find_children("*", "Area3D", true, false):
area.collision_layer = 0
area.collision_mask = 0
var anim := model.get_node_or_null("AnimationPlayer") as AnimationPlayer
if anim != null:
# The exported native scene already marks idle as looping.
anim.play("idle")
var mesh := SphereMesh.new()
mesh.radius = 0.055
mesh.height = 0.11
var glow := StandardMaterial3D.new()
glow.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
glow.albedo_color = Color(1, 0.7, 0.1)
mesh.material = glow
var marker := MeshInstance3D.new()
marker.mesh = mesh
marker.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(marker)
if direction.length_squared() > 0:
look_at(global_position + direction)
func _physics_process(dt: float) -> void:
if spent:
return
age += dt
if age >= LIFETIME or not is_instance_valid(shooter) or shooter.dead \
or not is_instance_valid(player) or player.hp <= 0:
queue_free()
return
var next := global_position + direction * SPEED * dt
var query := PhysicsRayQueryParameters3D.create(global_position, next, 1 | 2)
query.exclude = [shooter.get_rid()]
var hit := get_world_3d().direct_space_state.intersect_ray(query)
if not hit.is_empty():
spent = true
if hit.collider == player:
player.take_damage(damage)
queue_free()
else:
global_position = next
+1
View File
@@ -0,0 +1 @@
uid://0yeh2nqrpj3o
+83
View File
@@ -0,0 +1,83 @@
extends Node3D
## Cheap Compatibility-safe Xen arrival: rotating rings, CPU wisps, cyan flash.
signal materialized
const Audio = preload("res://scripts/hl_audio.gd")
const ARRIVAL_TIME := 1.5
var elapsed := 0.0
var announced := false
var rings: Array[MeshInstance3D] = []
var light: OmniLight3D
func _ready() -> void:
name = "BossTeleport"
Audio.play(self, "ambience/port_suckin1.wav", true, -9)
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
material.albedo_color = Color(0.12, 1.0, 0.65, 0.8)
for i in range(3):
var ring := MeshInstance3D.new()
ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
var mesh := TorusMesh.new()
mesh.inner_radius = 0.91
mesh.outer_radius = 1.0
mesh.rings = 32
mesh.ring_segments = 8
mesh.material = material
ring.mesh = mesh
ring.position.y = 0.10 + i * 0.72
add_child(ring)
rings.append(ring)
light = OmniLight3D.new()
light.light_color = Color(0.12, 1.0, 0.65)
light.light_energy = 3
light.omni_range = 6
light.shadow_enabled = false
light.position.y = 1.1
add_child(light)
var particles := CPUParticles3D.new()
particles.amount = 40
particles.lifetime = 1.0
particles.emitting = true
particles.emission_shape = CPUParticles3D.EMISSION_SHAPE_SPHERE
particles.emission_sphere_radius = 0.9
particles.position.y = 0.5
particles.direction = Vector3.UP
particles.spread = 20
particles.initial_velocity_min = 1
particles.initial_velocity_max = 2.2
particles.gravity = Vector3.ZERO
var quad := QuadMesh.new()
quad.size = Vector2(0.22, 0.4)
var mist := material.duplicate() as StandardMaterial3D
mist.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
mist.albedo_texture = preload("res://assets/effects/flame.png")
quad.material = mist
particles.mesh = quad
var colors := Gradient.new()
colors.set_color(0, Color(0.1, 1, 0.65, 0.8))
colors.set_color(1, Color(0.05, 0.3, 0.25, 0))
particles.color_ramp = colors
add_child(particles)
get_tree().create_timer(ARRIVAL_TIME).timeout.connect(func():
particles.emitting = false)
func _process(dt: float) -> void:
elapsed += dt
for i in rings.size():
var size := maxf(0.05, sin(minf(elapsed / 2.2, 1.0) * PI))
rings[i].scale = Vector3.ONE * size * (1.0 + 0.12 * sin(elapsed * 11 + i))
rings[i].rotation = Vector3(sin(elapsed * 4 + i) * 0.2, elapsed * 3, 0)
light.light_energy = 3 * maxf(0, 1.0 - maxf(0, elapsed - ARRIVAL_TIME) / 0.7)
if elapsed >= ARRIVAL_TIME and not announced:
announced = true
Audio.play(self, "ambience/port_suckout1.wav", true, -9)
materialized.emit()
if elapsed >= 2.2:
# Sound players belong to this node; leave them time to finish.
for ring in rings:
ring.hide()
light.hide()
set_process(false)
get_tree().create_timer(3).timeout.connect(queue_free)
+1
View File
@@ -0,0 +1 @@
uid://h5wtakci76b2
+37
View File
@@ -9,6 +9,9 @@ var wave_value: Label
var grenade_value: Label
var help: Label
var root: Control
var boss_panel: VBoxContainer
var boss_name: Label
var boss_health: ProgressBar
func _ready() -> void:
layer = 2
@@ -44,6 +47,31 @@ func _ready() -> void:
help.offset_right = 420
help.offset_top = -30
help.offset_bottom = -8
boss_panel = VBoxContainer.new()
boss_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(boss_panel)
boss_panel.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP)
boss_panel.offset_left = -180
boss_panel.offset_right = 180
boss_panel.offset_top = 76
boss_name = _label(boss_panel, "ALIEN GRUNT", 17, Vector2.ZERO)
boss_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
boss_health = ProgressBar.new()
boss_health.mouse_filter = Control.MOUSE_FILTER_IGNORE
boss_health.custom_minimum_size = Vector2(360, 18)
boss_health.show_percentage = false
boss_health.step = 0.0 # explosion falloff produces fractional health values
boss_health.rounded = false
var fill := StyleBoxFlat.new()
fill.bg_color = Color(0.14, 0.85, 0.5)
boss_health.add_theme_stylebox_override("fill", fill)
var background := StyleBoxFlat.new()
background.bg_color = Color(0.02, 0.05, 0.03, 0.9)
background.border_color = AMBER
background.set_border_width_all(1)
boss_health.add_theme_stylebox_override("background", background)
boss_panel.add_child(boss_health)
boss_panel.hide()
func _panel(label: String, right: bool) -> Panel:
var panel := Panel.new()
@@ -76,6 +104,15 @@ func _label(parent: Control, text: String, size: int, pos: Vector2) -> Label:
parent.add_child(label)
return label
func update_boss(boss: Node) -> void:
boss_panel.visible = is_instance_valid(boss) and not boss.dead
if not boss_panel.visible:
return
boss_name.text = "ALIEN GRUNT — GRADO %d%s" % [boss.rank,
" — ARRIVO" if boss.entering else " | %d / %d" % [ceili(boss.hp), ceili(boss.max_hp)]]
boss_health.max_value = boss.max_hp
boss_health.value = boss.hp
func update_status(player: Node, wave: int, score: int) -> void:
health_value.text = str(maxi(0, ceili(player.hp)))
health_value.add_theme_color_override("font_color", DANGER if player.hp <= 25 else AMBER)
+145 -3
View File
@@ -1,6 +1,8 @@
extends Node3D
const PlayerScript = preload("res://scripts/player.gd")
const ZombieScript = preload("res://scripts/zombie.gd")
const BossScript = preload("res://scripts/alien_grunt.gd")
const BossTeleport = preload("res://scripts/boss_teleport.gd")
const RoomScript = preload("res://scripts/room.gd")
const LeaderboardScript = preload("res://scripts/leaderboard.gd")
const StartScreenScript = preload("res://scripts/start_screen.gd")
@@ -26,15 +28,28 @@ var leaderboard: Node
var start_screen: CanvasLayer
var profile := {}
var score_submitted := false
var boss: CharacterBody3D
var boss_simulation := false
var simulation_wave := 0
var simulation_god := false
var simulation_finished := false
var simulation_restarting := false
var simulation_label: Label
var simulation_retry: Button
func _ready() -> void:
smoke = "--smoke" in OS.get_cmdline_user_args()
profile = Profile.load_profile()
if not _configure_simulation():
return
# A simulation must never read/write the real player's profile or public scores.
profile = {"name": "SIMULAZIONE", "player_id": "boss-simulation", "best": 0} \
if boss_simulation else Profile.load_profile()
room = Node3D.new()
room.set_script(RoomScript)
add_child(room)
player = CharacterBody3D.new()
player.set_script(PlayerScript)
player.invulnerable = boss_simulation and simulation_god
player.position = Vector3(0, 0.1, 0)
add_child(player)
var envnode := WorldEnvironment.new()
@@ -89,6 +104,12 @@ func _ready() -> void:
leaderboard.score_submitted.connect(_on_score_submitted)
leaderboard.submit_failed.connect(_on_submit_failed)
player.died.connect(_on_player_died)
if boss_simulation:
leaderboard.disabled = true
leaderboard.base_url = ""
_setup_simulation_ui()
call_deferred("_start_simulation")
return
start_screen = CanvasLayer.new()
start_screen.set_script(StartScreenScript)
start_screen.player = player
@@ -112,7 +133,7 @@ func _on_start_pressed(player_name: String) -> void:
func start_game(player_name: String, save_profile := true) -> void:
profile["name"] = player_name
if save_profile:
if save_profile and not boss_simulation:
Profile.save_profile(profile)
if not player.touch_mode:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
@@ -126,6 +147,11 @@ func start_game(player_name: String, save_profile := true) -> void:
## Alla morte si invia UNA volta il punteggio finale con nome e id del giocatore.
func _on_player_died() -> void:
if boss_simulation:
simulation_finished = false
simulation_retry.hide()
player.set_death_note("SIMULAZIONE — R / RICOMINCIA per riprovare; nessun punteggio inviato")
return
if score_submitted:
return
score_submitted = true
@@ -199,9 +225,13 @@ func _restart_music() -> void:
func _process(_dt: float) -> void:
if hud:
hud.update_status(player, wave, score)
hud.update_boss(boss)
static func is_boss_wave(number: int) -> bool:
return number > 0 and number % 10 == 0
func zombies_for_wave(number: int) -> int:
return 4 + number * 2
return 0 if is_boss_wave(number) else 4 + number * 2
func _next_wave() -> void:
wave_pending = false
@@ -209,6 +239,12 @@ func _next_wave() -> void:
return
wave += 1
spawning = true
if is_boss_wave(wave):
_spawn_boss()
spawning = false
_check_wave()
return
boss = null
for i in zombies_for_wave(wave):
if player.hp <= 0:
break
@@ -265,4 +301,110 @@ func _spawn() -> void:
func _check_wave() -> void:
if alive == 0 and not spawning and not wave_pending and player.hp > 0:
wave_pending = true
if boss_simulation:
simulation_finished = true
simulation_label.text = "BOSS SCONFITTO — ondata %d | R o RIPROVA per ricominciare" % wave
simulation_retry.show()
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
return
get_tree().create_timer(3).timeout.connect(_next_wave)
func _boss_spawn_position() -> Vector3:
var candidates := [Vector3(0, 0.05, -6), Vector3(6, 0.05, 0),
Vector3(0, 0.05, 6), Vector3(-6, 0.05, 0), Vector3(6, 0.05, 6),
Vector3(-6, 0.05, -6), Vector3(6, 0.05, -6), Vector3(-6, 0.05, 6)]
candidates.shuffle()
var capsule := CapsuleShape3D.new()
capsule.radius = 0.5
capsule.height = 2.5
for candidate: Vector3 in candidates:
if candidate.distance_to(player.global_position) < 5:
continue
var query := PhysicsShapeQueryParameters3D.new()
query.shape = capsule
query.transform = Transform3D(Basis.IDENTITY, candidate + Vector3.UP * 1.25)
query.collision_mask = 1 | 2 | 4
if get_world_3d().direct_space_state.intersect_shape(query, 1).is_empty() \
and navigation.route(candidate, player.global_position).size() > 1:
return candidate
# Fixed office layout has clear candidates; never silently spawn inside a prop.
return Vector3.INF
func _spawn_boss() -> void:
var at := _boss_spawn_position()
if not at.is_finite():
push_error("Nessun punto libero per il boss")
return
boss = BossScript.new()
boss.configure(wave)
boss.player = player
boss.navigation = navigation
boss.position = at
var instance := boss
alive += 1
boss.killed.connect(func(_headshot: bool):
score += instance.reward
alive -= 1
_check_wave())
add_child(boss)
var portal := BossTeleport.new()
portal.position = at
portal.materialized.connect(func():
if is_instance_valid(instance):
instance.materialize())
add_child(portal)
func _configure_simulation() -> bool:
if OS.has_feature("web"):
return true
for argument in OS.get_cmdline_user_args():
if argument.begins_with("--boss-wave="):
var value := argument.substr(12)
if not value.is_valid_int() or not is_boss_wave(int(value)):
push_error("--boss-wave richiede un multiplo positivo di 10: 10, 20, 30...")
get_tree().quit(2)
return false
simulation_wave = int(value)
boss_simulation = true
simulation_god = "--boss-god" in OS.get_cmdline_user_args()
return true
func _start_simulation() -> void:
# Wait for static physics shapes/navigation to be registered before choosing a portal.
await get_tree().physics_frame
wave = simulation_wave - 1
start_game("SIMULAZIONE", false)
print("BOSS SIMULATION: wave=", simulation_wave, " god=", simulation_god,
" | public scores/profile writes disabled")
func _setup_simulation_ui() -> void:
var canvas := CanvasLayer.new()
canvas.layer = 6
add_child(canvas)
simulation_label = Label.new()
simulation_label.text = "SIMULAZIONE BOSS — ONDATA %d%s | nessun punteggio online" % [
simulation_wave, " | INVULNERABILE" if simulation_god else ""]
simulation_label.position = Vector2(18, 125)
simulation_label.add_theme_font_size_override("font_size", 18)
simulation_label.add_theme_color_override("font_color", Color(0.2, 1, 0.7))
simulation_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
canvas.add_child(simulation_label)
simulation_retry = Button.new()
simulation_retry.text = "RIPROVA IL BOSS"
simulation_retry.position = Vector2(510, 520)
simulation_retry.size = Vector2(260, 60)
simulation_retry.pressed.connect(_restart_simulation)
simulation_retry.hide()
canvas.add_child(simulation_retry)
func _input(event: InputEvent) -> void:
# Only the completed native simulation has this shortcut. No menu click-to-start.
if boss_simulation and simulation_finished and event.is_action_pressed("reload"):
get_viewport().set_input_as_handled()
_restart_simulation()
func _restart_simulation() -> void:
if simulation_restarting:
return
simulation_restarting = true
get_tree().reload_current_scene()
+2 -1
View File
@@ -6,6 +6,7 @@ var damage_feedback: CanvasLayer
const DamageFeedback = preload("res://scripts/damage_feedback.gd")
const MAX_HP := 100.0
var hp := MAX_HP
var invulnerable := false # only enabled by the explicit native boss simulation flag
var _regen_elapsed := 0.0
## Discreto: +1 HP per secondo di gioco, mai oltre il massimo o dopo morte.
@@ -215,7 +216,7 @@ func throw_grenade() -> RigidBody3D:
return grenade
func take_damage(amount: float) -> void:
if death_started or amount <= 0:
if death_started or invulnerable or amount <= 0:
return
hp = maxf(0, hp - amount)
if hp <= 0:
+29
View File
@@ -0,0 +1,29 @@
extends SceneTree
## Persist native scenes: both native simulation and future Web exports need no MDL plugin.
func _initialize() -> void:
for stem in ["agrunt", "hornet"]:
var path := "res://assets/boss/%s_textured" % stem
var scene: PackedScene = load(path + ".mdl")
assert(scene != null)
var model := scene.instantiate()
var surfaces := 0
var bounds := AABB()
for mesh in model.find_children("*", "MeshInstance3D", true, false):
bounds = bounds.merge(mesh.transform * mesh.get_aabb())
for i in mesh.mesh.get_surface_count():
var material = mesh.mesh.surface_get_material(i)
assert(material is StandardMaterial3D and material.albedo_texture != null)
surfaces += 1
assert(surfaces > 0)
var anim: AnimationPlayer = model.get_node_or_null("AnimationPlayer")
if anim != null:
for label in anim.get_animation_list():
var clip := anim.get_animation(label)
clip.loop_mode = Animation.LOOP_LINEAR if label in ["idle1", "idle", "walk", "run"] else Animation.LOOP_NONE
print(stem, " animations=", anim.get_animation_list())
var packed := PackedScene.new()
assert(packed.pack(model) == OK)
assert(ResourceSaver.save(packed, path + ".scn") == OK)
print("NATIVE BOSS VERIFIED ", stem, " surfaces=", surfaces, " bounds=", bounds)
model.free()
quit()
+1
View File
@@ -0,0 +1 @@
uid://3kptgkh7bn63
+16 -2
View File
@@ -1,9 +1,10 @@
extends SceneTree
func _initialize() -> void:
for path in ["res://assets/weapons/v_9mmar", "res://assets/zombies/zombie_textured", "res://assets/weapons/grenade"]:
for path in ["res://assets/weapons/v_9mmar", "res://assets/zombies/zombie_textured",
"res://assets/weapons/grenade", "res://assets/boss/agrunt_textured",
"res://assets/boss/hornet_textured"]:
var scene: PackedScene = load(path + ".mdl")
assert(scene != null)
assert(ResourceSaver.save(scene, path + ".scn") == OK)
var node := scene.instantiate()
var textured := 0
for mesh in node.find_children("*", "MeshInstance3D", true, false):
@@ -12,6 +13,19 @@ func _initialize() -> void:
assert(material is StandardMaterial3D and material.albedo_texture != null)
textured += 1
assert(textured > 0)
if path.contains("/boss/"):
var anim := node.get_node_or_null("AnimationPlayer") as AnimationPlayer
if anim != null:
for label in anim.get_animation_list():
anim.get_animation(label).loop_mode = Animation.LOOP_LINEAR if label in [
"idle1", "idle", "walk", "run"] else Animation.LOOP_NONE
if path.contains("/boss/"):
var packed := PackedScene.new()
assert(packed.pack(node) == OK)
assert(ResourceSaver.save(packed, path + ".scn") == OK)
else:
# Preserve the established export of unchanged weapons/zombie scenes.
assert(ResourceSaver.save(scene, path + ".scn") == OK)
print("NATIVE SCENE VERIFIED ", path, " surfaces=", textured)
node.free()
quit()
+88
View File
@@ -0,0 +1,88 @@
"""Prepare original Alien Grunt / hornet assets, without changing Steam files.
Embed the companion texture MDL; keep only self-contained sequence-group-zero
animations. External group 1 contains cinematic sequences, not the combat set.
"""
import hashlib
import json
import shutil
import struct
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SOURCE = Path.home() / '.steam/steam/steamapps/common/Half-Life/valve'
DEST = ROOT / 'assets/boss'
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def prepare_model(name, companion=None):
src = SOURCE / 'models' / (name + '.mdl')
body = bytearray(src.read_bytes())
assert body[:4] == b'IDST' and struct.unpack_from('<i', body, 4)[0] == 10
sources = [{'path': 'models/' + src.name, 'sha256': digest(src)}]
if companion:
texpath = SOURCE / 'models' / companion
tex = bytearray(texpath.read_bytes())
base = len(body)
count, records, data, refs, families, skins = struct.unpack_from('<6i', tex, 180)
assert count > 0
for i in range(count):
pos = records + i * 80
pixel = struct.unpack_from('<i', tex, pos + 76)[0]
struct.pack_into('<i', tex, pos + 76, pixel + base)
struct.pack_into('<6i', body, 180, count, records + base, data + base,
refs, families, skins + base)
body.extend(tex)
sources.append({'path': 'models/' + companion, 'sha256': digest(texpath)})
count, offset = struct.unpack_from('<2i', body, 164)
sequences, omitted, used = [], [], set()
for i in range(count):
record = bytearray(body[offset + 176 * i:offset + 176 * (i + 1)])
label = bytes(record[:32]).split(b'\0')[0].decode('ascii')
group = struct.unpack_from('<i', record, 156)[0]
if group:
omitted.append({'index': i, 'label': label, 'external_group': group})
continue
unique = label
suffix = 2
while unique in used:
unique = label + '_' + str(suffix)
suffix += 1
used.add(unique)
record[:32] = unique.encode().ljust(32, b'\0')
sequences.append(record)
# All sequence offsets still address the original, unchanged body data.
struct.pack_into('<2i', body, 164, len(sequences), len(body))
body.extend(b''.join(sequences))
struct.pack_into('<i', body, 172, 1)
struct.pack_into('<i', body, 72, len(body))
out = DEST / (name + '_textured.mdl')
out.write_bytes(body)
return {'sources': sources, 'destination': str(out.relative_to(ROOT)),
'sha256': digest(out), 'animations': sorted(used), 'omitted_sequences': omitted}
def main():
DEST.mkdir(parents=True, exist_ok=True)
records = [prepare_model('agrunt', 'agruntt.mdl'), prepare_model('hornet')]
sounds = ['agrunt/ag_alert1.wav', 'agrunt/ag_attack1.wav', 'agrunt/ag_fire1.wav',
'agrunt/ag_idle1.wav', 'agrunt/ag_pain1.wav', 'agrunt/ag_die1.wav',
'ambience/port_suckin1.wav', 'ambience/port_suckout1.wav']
for relative in sounds:
src = SOURCE / 'sound' / relative
out = ROOT / 'assets/audio' / relative
out.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, out)
assert digest(src) == digest(out)
records.append({'source': 'sound/' + relative,
'destination': str(out.relative_to(ROOT)), 'sha256': digest(src)})
(DEST / 'source_manifest.json').write_text(json.dumps(records, indent=2) + '\n')
for r in records:
print(r.get('destination'), r['sha256'])
if __name__ == '__main__':
main()
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Interactive native-only sandbox: never submits scores or changes the saved name.
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
GODOT_BIN=${GODOT_BIN:-"$HOME/.local/opt/godot-4.7.1/Godot_v4.7.1-stable_linux.x86_64"}
WAVE=${1:-10}
if [ "$#" -gt 0 ]; then shift; fi
case "$WAVE" in ''|*[!0-9]*) echo 'Usage: tools/run_boss_simulation.sh [10|20|30|...] [--boss-god]' >&2; exit 2;; esac
if [ "$WAVE" -lt 10 ] || [ $((WAVE % 10)) -ne 0 ]; then
echo 'Boss wave must be a positive multiple of ten (10, 20, 30, ...).' >&2
exit 2
fi
exec "$GODOT_BIN" --path "$ROOT" --rendering-method gl_compatibility -- --boss-wave="$WAVE" "$@"
+47
View File
@@ -0,0 +1,47 @@
extends SceneTree
## Deterministic visual review in the real Compatibility renderer.
const Boss = preload("res://scripts/alien_grunt.gd")
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
root.size = Vector2i(1280, 720)
var world = load("res://scenes/main.tscn").instantiate()
root.add_child(world)
world.start_screen.hide()
world.spawning = true
world.player.set_physics_process(false)
world.player.position = Vector3(0, 0.1, 3.5)
world.player.camera.look_at(Vector3(0, 1.2, -3))
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
await physics_frame
var actor := Boss.new()
actor.configure(10)
actor.player = world.player
actor.navigation = world.navigation
actor.position = Vector3(0, 0.05, -3)
actor.rotation.y = PI
world.add_child(actor)
actor.set_physics_process(false)
world.boss = actor
world.wave = 10
var portal = load("res://scripts/boss_teleport.gd").new()
portal.position = actor.position
portal.materialized.connect(actor.materialize)
world.add_child(portal)
await create_timer(1.0).timeout
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("/tmp/zombie-boss-portal.png")
await create_timer(0.8).timeout
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("/tmp/zombie-boss-arrival.png")
await create_timer(1.8).timeout
actor.anim.play("hornetattack")
await create_timer(0.3).timeout
actor._fire_hornet()
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("/tmp/zombie-boss-combat.png")
print("SHOT: /tmp/zombie-boss-{portal,arrival,combat}.png")
world.queue_free()
await process_frame
quit()
+1
View File
@@ -0,0 +1 @@
uid://bifk8i65ile77
+223
View File
@@ -0,0 +1,223 @@
extends SceneTree
const Boss = preload("res://scripts/alien_grunt.gd")
const Hornet = preload("res://scripts/boss_hornet.gd")
const Main = preload("res://scripts/main.gd")
var checks := 0
var failures := 0
var killed_events := 0
func _initialize() -> void:
call_deferred("run_test")
func check(ok: bool, label: String) -> void:
checks += 1
if not ok:
failures += 1
print("PASS: " if ok else "FAIL: ", label)
func make_world() -> Node3D:
var world = load("res://scenes/main.tscn").instantiate()
root.add_child(world)
world.player.hp = 100000
world.player.set_physics_process(false)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
return world
func target_body(world: Node3D, at: Vector3, size: Vector3) -> StaticBody3D:
var body := StaticBody3D.new()
body.position = at
var collider := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = size
collider.shape = shape
body.add_child(collider)
world.add_child(body)
return body
func profile_snapshot() -> String:
if not FileAccess.file_exists(Main.Profile.PATH):
return "ABSENT"
return FileAccess.get_sha256(Main.Profile.PATH)
func run_test() -> void:
var profile_before := profile_snapshot()
check(not Main.is_boss_wave(0) and not Main.is_boss_wave(9) and Main.is_boss_wave(10)
and not Main.is_boss_wave(11) and Main.is_boss_wave(20), "boss solo ogni dieci ondate")
var a := Boss.stats_for_wave(10)
var b := Boss.stats_for_wave(20)
var c := Boss.stats_for_wave(30)
check(a.rank == 1 and b.rank == 2 and c.rank == 3 and a.hp == 800 and b.hp == 1200
and c.hp == 1600, "grado e salute crescono a ogni comparsa")
check(a.melee < b.melee and b.melee < c.melee and a.ranged < b.ranged
and b.ranged < c.ranged and a.speed < b.speed and b.interval < a.interval,
"danni, velocita' e cadenza crescono")
var late := Boss.stats_for_wave(10000)
check(late.speed <= 3.4 and late.interval >= 1.3, "mobilita' e cadenza hanno limiti giocabili")
var world = make_world()
await physics_frame
await physics_frame
check(world.zombies_for_wave(9) == 22 and world.zombies_for_wave(10) == 0
and world.zombies_for_wave(11) == 26, "ondata boss sostituisce gli zombie, non si somma")
for i in range(16):
var at: Vector3 = world._boss_spawn_position()
check(at.is_finite() and at.distance_to(world.player.global_position) >= 5,
"portale libero e distante: estrazione %d" % (i + 1))
# Real 9 -> 10 transition: finish all 22 ordinary enemies before the boss.
world.wave = 8
world.start_game("Test", false)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
check(world.wave == 9 and world.boss == null and world.spawning,
"ondata 9 ancora ordinaria")
await create_timer(18.0).timeout
check(world.alive == 22 and not world.spawning, "ondata 9 completa: 22 zombie, nessun boss")
for zombie in get_nodes_in_group("live_zombies"):
zombie.take_damage(1000000, zombie.global_position.y + 0.9)
await create_timer(3.2).timeout
var actor = world.boss
var score_before_boss: int = world.score
check(world.wave == 10 and world.alive == 1 and not world.spawning
and get_nodes_in_group("live_zombies").size() == 1
and actor.is_in_group("bosses"), "ondata 10: esattamente un boss e nessuno zombie ordinario")
check(actor.entering and not actor.model.visible and actor.collision_layer == 0,
"preavviso teletrasporto: boss non ancora attivo")
var hp_before: float = actor.hp
actor.take_damage(100, actor.position.y + 1)
world._check_wave()
check(actor.hp == hp_before and not world.wave_pending, "invulnerabile durante arrivo; niente salto ondata")
await create_timer(1.65).timeout
actor.set_physics_process(false)
check(not actor.entering and actor.model.visible and actor.collision_layer == 4,
"teletrasporto materializza modello e hitbox")
check(actor.anim.has_animation("walk") and actor.anim.has_animation("hornetattack")
and actor.anim.has_animation("mattack2") and actor.anim.has_animation("diesimple"),
"animazioni di combattimento e morte originali presenti")
var surfaces := 0
var textured := true
for mesh in actor.model.find_children("*", "MeshInstance3D", true, false):
for i in mesh.mesh.get_surface_count():
var material = mesh.mesh.surface_get_material(i)
textured = textured and material is StandardMaterial3D and material.albedo_texture != null
surfaces += 1
check(textured and surfaces > 0, "modello originale con texture incorporate, scena nativa")
# Stable open lane, actual hitscan and grenade blast (same live_zombies interface).
actor.position = Vector3(0, 0.05, -3)
world.player.position = Vector3(0, 0.1, 2)
world.player.camera.look_at(actor.global_position + Vector3.UP * 1.2)
await physics_frame
await physics_frame
hp_before = actor.hp
world.player.shoot()
check(actor.hp < hp_before, "fucile: il boss riceve i colpi tramite la normale hitbox")
var grenade = load("res://scripts/grenade.gd").new()
grenade.thrower = world.player
grenade.position = actor.position + Vector3(0, 0.4, 1)
world.add_child(grenade)
grenade.freeze = true
hp_before = actor.hp
grenade._request_detonation(grenade.global_position)
await process_frame
await process_frame
check(actor.hp < hp_before, "granata: il boss riceve il danno esplosivo")
world.hud.update_boss(actor)
check(world.hud.boss_panel.visible and world.hud.boss_health.value == actor.hp
and world.hud.boss_health.max_value == actor.max_hp, "barra vita del boss segue i danni")
# Real hornet flight towards player, with a separate obstruction regression.
var player_hp: float = world.player.hp
actor._fire_hornet()
check(get_nodes_in_group("boss_projectiles").size() == 1, "attacco alieno crea un proiettile visibile")
await create_timer(0.8).timeout
check(world.player.hp < player_hp, "proiettile alieno colpisce e danneggia il giocatore")
var wall := target_body(world, Vector3(0, 1.4, -0.5), Vector3(3, 3, 0.15))
await physics_frame
await physics_frame
player_hp = world.player.hp
actor._fire_hornet()
await create_timer(0.8).timeout
check(world.player.hp == player_hp and get_nodes_in_group("boss_projectiles").is_empty(),
"parete ferma i proiettili: niente colpi attraverso gli oggetti")
wall.queue_free()
await process_frame
# Melee cannot reach through the existing desk.
world.player.position = Vector3(-5, 0.1, -3.2)
actor.position = Vector3(-5, 0.05, -4.8)
await physics_frame
await physics_frame
player_hp = world.player.hp
actor._attack()
check(world.player.hp == player_hp, "corpo a corpo bloccato dalla scrivania")
actor.position = Vector3(0, 0.05, -1.5)
world.player.position = Vector3(0, 0.1, 0)
await physics_frame
await physics_frame
actor._attack()
check(world.player.hp < player_hp, "corpo a corpo infligge danno a distanza utile")
# AI pathfinding actually moves around an office desk, without teleport tricks.
var routes := [
[Vector3(-5, 0.05, -6.5), Vector3(-5, 0.1, 0)],
[Vector3(5, 0.05, 1.5), Vector3(5, 0.1, 7.5)],
[Vector3(-6, 0.05, 4), Vector3(-2, 0.1, 4)],
[Vector3(6.8, 0.05, -6), Vector3(6.8, 0.1, -2)],
]
for route in routes:
world.player.position = route[1]
actor.position = route[0]
actor.cooldown = 100
actor.attack_lock = 0
actor._nav_started = false
actor._repath = 0
actor.set_physics_process(true)
var reached := false
for i in range(650):
await physics_frame
if actor.position.distance_to(world.player.position) < 2.3:
reached = true
break
actor.set_physics_process(false)
check(reached, "boss aggira gli arredi: %s -> %s" % [route[0], route[1]])
# A queued burst must never fire after death.
actor._start_burst()
actor.killed.connect(func(_head): killed_events += 1)
actor.take_damage(1000000, actor.global_position.y + 1)
actor.take_damage(1000000, actor.global_position.y + 1)
check(killed_events == 1 and world.alive == 0 and world.score == score_before_boss + actor.reward
and world.wave_pending, "morte unica, ricompensa unica e completamento ondata boss")
world.hud.update_boss(actor)
check(not world.hud.boss_panel.visible and actor.collision_layer == 0
and not actor.is_in_group("live_zombies"), "morto: HUD nascosto, corpo non blocca e non conta vivo")
await create_timer(0.8).timeout
check(get_nodes_in_group("boss_projectiles").is_empty(), "raffica pendente annullata dalla morte")
await create_timer(2.5).timeout
check(world.wave == 11 and world.boss == null and world.alive > 0
and get_nodes_in_group("bosses").is_empty(), "10 -> 11: riprendono gli zombie normali una sola volta")
world.queue_free()
await process_frame
# Native sandbox: no start menu/network/profile; stops after the selected boss.
world = load("res://scenes/main.tscn").instantiate()
world.boss_simulation = true
world.simulation_wave = 20
root.add_child(world)
world.player.hp = 100000
world.player.set_physics_process(false)
await create_timer(1.8).timeout
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
actor = world.boss
actor.set_physics_process(false)
check(world.wave == 20 and actor.rank == 2 and actor.max_hp == 1200
and world.start_screen == null and world.leaderboard.disabled
and world.leaderboard._pending.is_empty(), "simulazione diretta grado 2, senza menu/classifica")
actor.take_damage(1000000, actor.position.y + 1)
await create_timer(3.2).timeout
check(world.simulation_finished and world.simulation_retry.visible and world.wave == 20
and world.alive == 0, "simulazione termina nella singola ondata; pulsante RIPROVA")
world.player.invulnerable = true
world.player.take_damage(1000000)
check(world.player.hp > 0, "opzione invulnerabile per prove native")
world.player.invulnerable = false
world.player.take_damage(1000000)
check(world.leaderboard._pending.is_empty() and not world.score_submitted
and world.profile.best == 0, "morte in simulazione non invia punteggi e non aggiorna il record")
check(profile_before == profile_snapshot(), "profilo persistente byte-identico dopo i test")
world.queue_free()
await process_frame
print(checks - failures, " controlli superati, ", failures, " falliti (", checks, " totali)")
quit(1 if failures else 0)
+1
View File
@@ -0,0 +1 @@
uid://dmkobdmpjqiai