diff --git a/assets/audio/agrunt/ag_alert1.wav b/assets/audio/agrunt/ag_alert1.wav new file mode 100644 index 0000000..c3bf8cc Binary files /dev/null and b/assets/audio/agrunt/ag_alert1.wav differ diff --git a/assets/audio/agrunt/ag_alert1.wav.import b/assets/audio/agrunt/ag_alert1.wav.import new file mode 100644 index 0000000..53b2c07 --- /dev/null +++ b/assets/audio/agrunt/ag_alert1.wav.import @@ -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 diff --git a/assets/audio/agrunt/ag_attack1.wav b/assets/audio/agrunt/ag_attack1.wav new file mode 100644 index 0000000..a2e032e Binary files /dev/null and b/assets/audio/agrunt/ag_attack1.wav differ diff --git a/assets/audio/agrunt/ag_attack1.wav.import b/assets/audio/agrunt/ag_attack1.wav.import new file mode 100644 index 0000000..ccf6f64 --- /dev/null +++ b/assets/audio/agrunt/ag_attack1.wav.import @@ -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 diff --git a/assets/audio/agrunt/ag_die1.wav b/assets/audio/agrunt/ag_die1.wav new file mode 100644 index 0000000..21029f0 Binary files /dev/null and b/assets/audio/agrunt/ag_die1.wav differ diff --git a/assets/audio/agrunt/ag_die1.wav.import b/assets/audio/agrunt/ag_die1.wav.import new file mode 100644 index 0000000..cdb4b14 --- /dev/null +++ b/assets/audio/agrunt/ag_die1.wav.import @@ -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 diff --git a/assets/audio/agrunt/ag_fire1.wav b/assets/audio/agrunt/ag_fire1.wav new file mode 100644 index 0000000..a835908 Binary files /dev/null and b/assets/audio/agrunt/ag_fire1.wav differ diff --git a/assets/audio/agrunt/ag_fire1.wav.import b/assets/audio/agrunt/ag_fire1.wav.import new file mode 100644 index 0000000..366db29 --- /dev/null +++ b/assets/audio/agrunt/ag_fire1.wav.import @@ -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 diff --git a/assets/audio/agrunt/ag_idle1.wav b/assets/audio/agrunt/ag_idle1.wav new file mode 100644 index 0000000..414166f Binary files /dev/null and b/assets/audio/agrunt/ag_idle1.wav differ diff --git a/assets/audio/agrunt/ag_idle1.wav.import b/assets/audio/agrunt/ag_idle1.wav.import new file mode 100644 index 0000000..bc4ec2f --- /dev/null +++ b/assets/audio/agrunt/ag_idle1.wav.import @@ -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 diff --git a/assets/audio/agrunt/ag_pain1.wav b/assets/audio/agrunt/ag_pain1.wav new file mode 100644 index 0000000..b027b47 Binary files /dev/null and b/assets/audio/agrunt/ag_pain1.wav differ diff --git a/assets/audio/agrunt/ag_pain1.wav.import b/assets/audio/agrunt/ag_pain1.wav.import new file mode 100644 index 0000000..b576c98 --- /dev/null +++ b/assets/audio/agrunt/ag_pain1.wav.import @@ -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 diff --git a/assets/audio/ambience/port_suckin1.wav b/assets/audio/ambience/port_suckin1.wav new file mode 100644 index 0000000..5c3a663 Binary files /dev/null and b/assets/audio/ambience/port_suckin1.wav differ diff --git a/assets/audio/ambience/port_suckin1.wav.import b/assets/audio/ambience/port_suckin1.wav.import new file mode 100644 index 0000000..7e86954 --- /dev/null +++ b/assets/audio/ambience/port_suckin1.wav.import @@ -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 diff --git a/assets/audio/ambience/port_suckout1.wav b/assets/audio/ambience/port_suckout1.wav new file mode 100644 index 0000000..c8307ae Binary files /dev/null and b/assets/audio/ambience/port_suckout1.wav differ diff --git a/assets/audio/ambience/port_suckout1.wav.import b/assets/audio/ambience/port_suckout1.wav.import new file mode 100644 index 0000000..2f1d6cc --- /dev/null +++ b/assets/audio/ambience/port_suckout1.wav.import @@ -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 diff --git a/assets/boss/agrunt_textured.mdl b/assets/boss/agrunt_textured.mdl new file mode 100644 index 0000000..84b58e2 Binary files /dev/null and b/assets/boss/agrunt_textured.mdl differ diff --git a/assets/boss/agrunt_textured.mdl.import b/assets/boss/agrunt_textured.mdl.import new file mode 100644 index 0000000..74c0009 --- /dev/null +++ b/assets/boss/agrunt_textured.mdl.import @@ -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 diff --git a/assets/boss/agrunt_textured.scn b/assets/boss/agrunt_textured.scn new file mode 100644 index 0000000..1d40d78 Binary files /dev/null and b/assets/boss/agrunt_textured.scn differ diff --git a/assets/boss/hornet_textured.mdl b/assets/boss/hornet_textured.mdl new file mode 100644 index 0000000..4d96a73 Binary files /dev/null and b/assets/boss/hornet_textured.mdl differ diff --git a/assets/boss/hornet_textured.mdl.import b/assets/boss/hornet_textured.mdl.import new file mode 100644 index 0000000..36842a2 --- /dev/null +++ b/assets/boss/hornet_textured.mdl.import @@ -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 diff --git a/assets/boss/hornet_textured.scn b/assets/boss/hornet_textured.scn new file mode 100644 index 0000000..0dbaaf2 Binary files /dev/null and b/assets/boss/hornet_textured.scn differ diff --git a/assets/boss/source_manifest.json b/assets/boss/source_manifest.json new file mode 100644 index 0000000..52c5624 --- /dev/null +++ b/assets/boss/source_manifest.json @@ -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" + } +] diff --git a/docs/BOSS.md b/docs/BOSS.md new file mode 100644 index 0000000..10e1e66 --- /dev/null +++ b/docs/BOSS.md @@ -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). diff --git a/docs/GRENADES-WAVES.md b/docs/GRENADES-WAVES.md index c312975..054dbc2 100644 --- a/docs/GRENADES-WAVES.md +++ b/docs/GRENADES-WAVES.md @@ -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 diff --git a/docs/WEB-DEPLOY.md b/docs/WEB-DEPLOY.md index 261d349..9f5c9a9 100644 --- a/docs/WEB-DEPLOY.md +++ b/docs/WEB-DEPLOY.md @@ -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; diff --git a/scripts/alien_grunt.gd b/scripts/alien_grunt.gd new file mode 100644 index 0000000..cd69136 --- /dev/null +++ b/scripts/alien_grunt.gd @@ -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) diff --git a/scripts/alien_grunt.gd.uid b/scripts/alien_grunt.gd.uid new file mode 100644 index 0000000..6147164 --- /dev/null +++ b/scripts/alien_grunt.gd.uid @@ -0,0 +1 @@ +uid://dgjgrwojm7qea diff --git a/scripts/boss_hornet.gd b/scripts/boss_hornet.gd new file mode 100644 index 0000000..e4e8500 --- /dev/null +++ b/scripts/boss_hornet.gd @@ -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 diff --git a/scripts/boss_hornet.gd.uid b/scripts/boss_hornet.gd.uid new file mode 100644 index 0000000..c112a61 --- /dev/null +++ b/scripts/boss_hornet.gd.uid @@ -0,0 +1 @@ +uid://0yeh2nqrpj3o diff --git a/scripts/boss_teleport.gd b/scripts/boss_teleport.gd new file mode 100644 index 0000000..f59dd9d --- /dev/null +++ b/scripts/boss_teleport.gd @@ -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) diff --git a/scripts/boss_teleport.gd.uid b/scripts/boss_teleport.gd.uid new file mode 100644 index 0000000..429c204 --- /dev/null +++ b/scripts/boss_teleport.gd.uid @@ -0,0 +1 @@ +uid://h5wtakci76b2 diff --git a/scripts/game_hud.gd b/scripts/game_hud.gd index 51452d4..6238f45 100644 --- a/scripts/game_hud.gd +++ b/scripts/game_hud.gd @@ -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) diff --git a/scripts/main.gd b/scripts/main.gd index 675b615..52e6ebd 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -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() diff --git a/scripts/player.gd b/scripts/player.gd index 79881fc..640355e 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -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: diff --git a/tools/export_boss_assets.gd b/tools/export_boss_assets.gd new file mode 100644 index 0000000..41f4f91 --- /dev/null +++ b/tools/export_boss_assets.gd @@ -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() diff --git a/tools/export_boss_assets.gd.uid b/tools/export_boss_assets.gd.uid new file mode 100644 index 0000000..733a239 --- /dev/null +++ b/tools/export_boss_assets.gd.uid @@ -0,0 +1 @@ +uid://3kptgkh7bn63 diff --git a/tools/export_web_assets.gd b/tools/export_web_assets.gd index 21f38f9..cdde651 100644 --- a/tools/export_web_assets.gd +++ b/tools/export_web_assets.gd @@ -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() diff --git a/tools/extract_boss_assets.py b/tools/extract_boss_assets.py new file mode 100644 index 0000000..b0bac22 --- /dev/null +++ b/tools/extract_boss_assets.py @@ -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(' 0 + for i in range(count): + pos = records + i * 80 + pixel = struct.unpack_from('&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" "$@" diff --git a/tools/shot_boss.gd b/tools/shot_boss.gd new file mode 100644 index 0000000..f9a6451 --- /dev/null +++ b/tools/shot_boss.gd @@ -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() diff --git a/tools/shot_boss.gd.uid b/tools/shot_boss.gd.uid new file mode 100644 index 0000000..41b081f --- /dev/null +++ b/tools/shot_boss.gd.uid @@ -0,0 +1 @@ +uid://bifk8i65ile77 diff --git a/tools/test_boss.gd b/tools/test_boss.gd new file mode 100644 index 0000000..ce9d236 --- /dev/null +++ b/tools/test_boss.gd @@ -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) diff --git a/tools/test_boss.gd.uid b/tools/test_boss.gd.uid new file mode 100644 index 0000000..f2c60f5 --- /dev/null +++ b/tools/test_boss.gd.uid @@ -0,0 +1 @@ +uid://dmkobdmpjqiai