Add scaling Alien Grunt boss waves and native simulation; publish Web release
This commit is contained in:
Binary file not shown.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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
@@ -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).
|
||||||
@@ -50,9 +50,11 @@ Web and explosion WAVs; provenance `assets/half_life_manifest.json`, extraction
|
|||||||
|
|
||||||
## Growing waves
|
## Growing waves
|
||||||
|
|
||||||
Wave size: **4 + 2 × wave** (6, 8, 10, ...), no old cap of 24. Next wave three seconds
|
Ordinary wave size: **4 + 2 × wave** (6, 8, 10, ...), no old cap of 24. Every tenth
|
||||||
after all enemies die and spawning completes. Scheduling guard prevents duplicate
|
wave (10, 20, 30, ...) is instead **one Alien Grunt boss**, with no ordinary zombies;
|
||||||
wave timers. Corpses remain.
|
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
|
## Tests and visual verification
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
# Web build and compressed deployment
|
# 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
|
## Contact grenade / original Half-Life fireball: add7baaef2b022ef
|
||||||
|
|
||||||
First-impact detonation (floor, props, walls, enemies and glass), no old timed fuse;
|
First-impact detonation (floor, props, walls, enemies and glass), no old timed fuse;
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dgjgrwojm7qea
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://0yeh2nqrpj3o
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://h5wtakci76b2
|
||||||
@@ -9,6 +9,9 @@ var wave_value: Label
|
|||||||
var grenade_value: Label
|
var grenade_value: Label
|
||||||
var help: Label
|
var help: Label
|
||||||
var root: Control
|
var root: Control
|
||||||
|
var boss_panel: VBoxContainer
|
||||||
|
var boss_name: Label
|
||||||
|
var boss_health: ProgressBar
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
layer = 2
|
layer = 2
|
||||||
@@ -44,6 +47,31 @@ func _ready() -> void:
|
|||||||
help.offset_right = 420
|
help.offset_right = 420
|
||||||
help.offset_top = -30
|
help.offset_top = -30
|
||||||
help.offset_bottom = -8
|
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:
|
func _panel(label: String, right: bool) -> Panel:
|
||||||
var panel := Panel.new()
|
var panel := Panel.new()
|
||||||
@@ -76,6 +104,15 @@ func _label(parent: Control, text: String, size: int, pos: Vector2) -> Label:
|
|||||||
parent.add_child(label)
|
parent.add_child(label)
|
||||||
return 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:
|
func update_status(player: Node, wave: int, score: int) -> void:
|
||||||
health_value.text = str(maxi(0, ceili(player.hp)))
|
health_value.text = str(maxi(0, ceili(player.hp)))
|
||||||
health_value.add_theme_color_override("font_color", DANGER if player.hp <= 25 else AMBER)
|
health_value.add_theme_color_override("font_color", DANGER if player.hp <= 25 else AMBER)
|
||||||
|
|||||||
+145
-3
@@ -1,6 +1,8 @@
|
|||||||
extends Node3D
|
extends Node3D
|
||||||
const PlayerScript = preload("res://scripts/player.gd")
|
const PlayerScript = preload("res://scripts/player.gd")
|
||||||
const ZombieScript = preload("res://scripts/zombie.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 RoomScript = preload("res://scripts/room.gd")
|
||||||
const LeaderboardScript = preload("res://scripts/leaderboard.gd")
|
const LeaderboardScript = preload("res://scripts/leaderboard.gd")
|
||||||
const StartScreenScript = preload("res://scripts/start_screen.gd")
|
const StartScreenScript = preload("res://scripts/start_screen.gd")
|
||||||
@@ -26,15 +28,28 @@ var leaderboard: Node
|
|||||||
var start_screen: CanvasLayer
|
var start_screen: CanvasLayer
|
||||||
var profile := {}
|
var profile := {}
|
||||||
var score_submitted := false
|
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:
|
func _ready() -> void:
|
||||||
smoke = "--smoke" in OS.get_cmdline_user_args()
|
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 = Node3D.new()
|
||||||
room.set_script(RoomScript)
|
room.set_script(RoomScript)
|
||||||
add_child(room)
|
add_child(room)
|
||||||
player = CharacterBody3D.new()
|
player = CharacterBody3D.new()
|
||||||
player.set_script(PlayerScript)
|
player.set_script(PlayerScript)
|
||||||
|
player.invulnerable = boss_simulation and simulation_god
|
||||||
player.position = Vector3(0, 0.1, 0)
|
player.position = Vector3(0, 0.1, 0)
|
||||||
add_child(player)
|
add_child(player)
|
||||||
var envnode := WorldEnvironment.new()
|
var envnode := WorldEnvironment.new()
|
||||||
@@ -89,6 +104,12 @@ func _ready() -> void:
|
|||||||
leaderboard.score_submitted.connect(_on_score_submitted)
|
leaderboard.score_submitted.connect(_on_score_submitted)
|
||||||
leaderboard.submit_failed.connect(_on_submit_failed)
|
leaderboard.submit_failed.connect(_on_submit_failed)
|
||||||
player.died.connect(_on_player_died)
|
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 = CanvasLayer.new()
|
||||||
start_screen.set_script(StartScreenScript)
|
start_screen.set_script(StartScreenScript)
|
||||||
start_screen.player = player
|
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:
|
func start_game(player_name: String, save_profile := true) -> void:
|
||||||
profile["name"] = player_name
|
profile["name"] = player_name
|
||||||
if save_profile:
|
if save_profile and not boss_simulation:
|
||||||
Profile.save_profile(profile)
|
Profile.save_profile(profile)
|
||||||
if not player.touch_mode:
|
if not player.touch_mode:
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
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.
|
## Alla morte si invia UNA volta il punteggio finale con nome e id del giocatore.
|
||||||
func _on_player_died() -> void:
|
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:
|
if score_submitted:
|
||||||
return
|
return
|
||||||
score_submitted = true
|
score_submitted = true
|
||||||
@@ -199,9 +225,13 @@ func _restart_music() -> void:
|
|||||||
func _process(_dt: float) -> void:
|
func _process(_dt: float) -> void:
|
||||||
if hud:
|
if hud:
|
||||||
hud.update_status(player, wave, score)
|
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:
|
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:
|
func _next_wave() -> void:
|
||||||
wave_pending = false
|
wave_pending = false
|
||||||
@@ -209,6 +239,12 @@ func _next_wave() -> void:
|
|||||||
return
|
return
|
||||||
wave += 1
|
wave += 1
|
||||||
spawning = true
|
spawning = true
|
||||||
|
if is_boss_wave(wave):
|
||||||
|
_spawn_boss()
|
||||||
|
spawning = false
|
||||||
|
_check_wave()
|
||||||
|
return
|
||||||
|
boss = null
|
||||||
for i in zombies_for_wave(wave):
|
for i in zombies_for_wave(wave):
|
||||||
if player.hp <= 0:
|
if player.hp <= 0:
|
||||||
break
|
break
|
||||||
@@ -265,4 +301,110 @@ func _spawn() -> void:
|
|||||||
func _check_wave() -> void:
|
func _check_wave() -> void:
|
||||||
if alive == 0 and not spawning and not wave_pending and player.hp > 0:
|
if alive == 0 and not spawning and not wave_pending and player.hp > 0:
|
||||||
wave_pending = true
|
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)
|
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
@@ -6,6 +6,7 @@ var damage_feedback: CanvasLayer
|
|||||||
const DamageFeedback = preload("res://scripts/damage_feedback.gd")
|
const DamageFeedback = preload("res://scripts/damage_feedback.gd")
|
||||||
const MAX_HP := 100.0
|
const MAX_HP := 100.0
|
||||||
var hp := MAX_HP
|
var hp := MAX_HP
|
||||||
|
var invulnerable := false # only enabled by the explicit native boss simulation flag
|
||||||
var _regen_elapsed := 0.0
|
var _regen_elapsed := 0.0
|
||||||
|
|
||||||
## Discreto: +1 HP per secondo di gioco, mai oltre il massimo o dopo morte.
|
## Discreto: +1 HP per secondo di gioco, mai oltre il massimo o dopo morte.
|
||||||
@@ -215,7 +216,7 @@ func throw_grenade() -> RigidBody3D:
|
|||||||
return grenade
|
return grenade
|
||||||
|
|
||||||
func take_damage(amount: float) -> void:
|
func take_damage(amount: float) -> void:
|
||||||
if death_started or amount <= 0:
|
if death_started or invulnerable or amount <= 0:
|
||||||
return
|
return
|
||||||
hp = maxf(0, hp - amount)
|
hp = maxf(0, hp - amount)
|
||||||
if hp <= 0:
|
if hp <= 0:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://3kptgkh7bn63
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
extends SceneTree
|
extends SceneTree
|
||||||
func _initialize() -> void:
|
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")
|
var scene: PackedScene = load(path + ".mdl")
|
||||||
assert(scene != null)
|
assert(scene != null)
|
||||||
assert(ResourceSaver.save(scene, path + ".scn") == OK)
|
|
||||||
var node := scene.instantiate()
|
var node := scene.instantiate()
|
||||||
var textured := 0
|
var textured := 0
|
||||||
for mesh in node.find_children("*", "MeshInstance3D", true, false):
|
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)
|
assert(material is StandardMaterial3D and material.albedo_texture != null)
|
||||||
textured += 1
|
textured += 1
|
||||||
assert(textured > 0)
|
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)
|
print("NATIVE SCENE VERIFIED ", path, " surfaces=", textured)
|
||||||
node.free()
|
node.free()
|
||||||
quit()
|
quit()
|
||||||
|
|||||||
@@ -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()
|
||||||
Executable
+13
@@ -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" "$@"
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bifk8i65ile77
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dmkobdmpjqiai
|
||||||
Reference in New Issue
Block a user