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

This commit is contained in:
enne2
2026-10-15 15:08:52 +02:00
parent 5761e160c1
commit 2606fc5cc2
44 changed files with 1464 additions and 9 deletions
+29
View File
@@ -0,0 +1,29 @@
extends SceneTree
## Persist native scenes: both native simulation and future Web exports need no MDL plugin.
func _initialize() -> void:
for stem in ["agrunt", "hornet"]:
var path := "res://assets/boss/%s_textured" % stem
var scene: PackedScene = load(path + ".mdl")
assert(scene != null)
var model := scene.instantiate()
var surfaces := 0
var bounds := AABB()
for mesh in model.find_children("*", "MeshInstance3D", true, false):
bounds = bounds.merge(mesh.transform * mesh.get_aabb())
for i in mesh.mesh.get_surface_count():
var material = mesh.mesh.surface_get_material(i)
assert(material is StandardMaterial3D and material.albedo_texture != null)
surfaces += 1
assert(surfaces > 0)
var anim: AnimationPlayer = model.get_node_or_null("AnimationPlayer")
if anim != null:
for label in anim.get_animation_list():
var clip := anim.get_animation(label)
clip.loop_mode = Animation.LOOP_LINEAR if label in ["idle1", "idle", "walk", "run"] else Animation.LOOP_NONE
print(stem, " animations=", anim.get_animation_list())
var packed := PackedScene.new()
assert(packed.pack(model) == OK)
assert(ResourceSaver.save(packed, path + ".scn") == OK)
print("NATIVE BOSS VERIFIED ", stem, " surfaces=", surfaces, " bounds=", bounds)
model.free()
quit()
+1
View File
@@ -0,0 +1 @@
uid://3kptgkh7bn63
+16 -2
View File
@@ -1,9 +1,10 @@
extends SceneTree
func _initialize() -> void:
for path in ["res://assets/weapons/v_9mmar", "res://assets/zombies/zombie_textured", "res://assets/weapons/grenade"]:
for path in ["res://assets/weapons/v_9mmar", "res://assets/zombies/zombie_textured",
"res://assets/weapons/grenade", "res://assets/boss/agrunt_textured",
"res://assets/boss/hornet_textured"]:
var scene: PackedScene = load(path + ".mdl")
assert(scene != null)
assert(ResourceSaver.save(scene, path + ".scn") == OK)
var node := scene.instantiate()
var textured := 0
for mesh in node.find_children("*", "MeshInstance3D", true, false):
@@ -12,6 +13,19 @@ func _initialize() -> void:
assert(material is StandardMaterial3D and material.albedo_texture != null)
textured += 1
assert(textured > 0)
if path.contains("/boss/"):
var anim := node.get_node_or_null("AnimationPlayer") as AnimationPlayer
if anim != null:
for label in anim.get_animation_list():
anim.get_animation(label).loop_mode = Animation.LOOP_LINEAR if label in [
"idle1", "idle", "walk", "run"] else Animation.LOOP_NONE
if path.contains("/boss/"):
var packed := PackedScene.new()
assert(packed.pack(node) == OK)
assert(ResourceSaver.save(packed, path + ".scn") == OK)
else:
# Preserve the established export of unchanged weapons/zombie scenes.
assert(ResourceSaver.save(scene, path + ".scn") == OK)
print("NATIVE SCENE VERIFIED ", path, " surfaces=", textured)
node.free()
quit()
+88
View File
@@ -0,0 +1,88 @@
"""Prepare original Alien Grunt / hornet assets, without changing Steam files.
Embed the companion texture MDL; keep only self-contained sequence-group-zero
animations. External group 1 contains cinematic sequences, not the combat set.
"""
import hashlib
import json
import shutil
import struct
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SOURCE = Path.home() / '.steam/steam/steamapps/common/Half-Life/valve'
DEST = ROOT / 'assets/boss'
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def prepare_model(name, companion=None):
src = SOURCE / 'models' / (name + '.mdl')
body = bytearray(src.read_bytes())
assert body[:4] == b'IDST' and struct.unpack_from('<i', body, 4)[0] == 10
sources = [{'path': 'models/' + src.name, 'sha256': digest(src)}]
if companion:
texpath = SOURCE / 'models' / companion
tex = bytearray(texpath.read_bytes())
base = len(body)
count, records, data, refs, families, skins = struct.unpack_from('<6i', tex, 180)
assert count > 0
for i in range(count):
pos = records + i * 80
pixel = struct.unpack_from('<i', tex, pos + 76)[0]
struct.pack_into('<i', tex, pos + 76, pixel + base)
struct.pack_into('<6i', body, 180, count, records + base, data + base,
refs, families, skins + base)
body.extend(tex)
sources.append({'path': 'models/' + companion, 'sha256': digest(texpath)})
count, offset = struct.unpack_from('<2i', body, 164)
sequences, omitted, used = [], [], set()
for i in range(count):
record = bytearray(body[offset + 176 * i:offset + 176 * (i + 1)])
label = bytes(record[:32]).split(b'\0')[0].decode('ascii')
group = struct.unpack_from('<i', record, 156)[0]
if group:
omitted.append({'index': i, 'label': label, 'external_group': group})
continue
unique = label
suffix = 2
while unique in used:
unique = label + '_' + str(suffix)
suffix += 1
used.add(unique)
record[:32] = unique.encode().ljust(32, b'\0')
sequences.append(record)
# All sequence offsets still address the original, unchanged body data.
struct.pack_into('<2i', body, 164, len(sequences), len(body))
body.extend(b''.join(sequences))
struct.pack_into('<i', body, 172, 1)
struct.pack_into('<i', body, 72, len(body))
out = DEST / (name + '_textured.mdl')
out.write_bytes(body)
return {'sources': sources, 'destination': str(out.relative_to(ROOT)),
'sha256': digest(out), 'animations': sorted(used), 'omitted_sequences': omitted}
def main():
DEST.mkdir(parents=True, exist_ok=True)
records = [prepare_model('agrunt', 'agruntt.mdl'), prepare_model('hornet')]
sounds = ['agrunt/ag_alert1.wav', 'agrunt/ag_attack1.wav', 'agrunt/ag_fire1.wav',
'agrunt/ag_idle1.wav', 'agrunt/ag_pain1.wav', 'agrunt/ag_die1.wav',
'ambience/port_suckin1.wav', 'ambience/port_suckout1.wav']
for relative in sounds:
src = SOURCE / 'sound' / relative
out = ROOT / 'assets/audio' / relative
out.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, out)
assert digest(src) == digest(out)
records.append({'source': 'sound/' + relative,
'destination': str(out.relative_to(ROOT)), 'sha256': digest(src)})
(DEST / 'source_manifest.json').write_text(json.dumps(records, indent=2) + '\n')
for r in records:
print(r.get('destination'), r['sha256'])
if __name__ == '__main__':
main()
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Interactive native-only sandbox: never submits scores or changes the saved name.
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
GODOT_BIN=${GODOT_BIN:-"$HOME/.local/opt/godot-4.7.1/Godot_v4.7.1-stable_linux.x86_64"}
WAVE=${1:-10}
if [ "$#" -gt 0 ]; then shift; fi
case "$WAVE" in ''|*[!0-9]*) echo 'Usage: tools/run_boss_simulation.sh [10|20|30|...] [--boss-god]' >&2; exit 2;; esac
if [ "$WAVE" -lt 10 ] || [ $((WAVE % 10)) -ne 0 ]; then
echo 'Boss wave must be a positive multiple of ten (10, 20, 30, ...).' >&2
exit 2
fi
exec "$GODOT_BIN" --path "$ROOT" --rendering-method gl_compatibility -- --boss-wave="$WAVE" "$@"
+47
View File
@@ -0,0 +1,47 @@
extends SceneTree
## Deterministic visual review in the real Compatibility renderer.
const Boss = preload("res://scripts/alien_grunt.gd")
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
root.size = Vector2i(1280, 720)
var world = load("res://scenes/main.tscn").instantiate()
root.add_child(world)
world.start_screen.hide()
world.spawning = true
world.player.set_physics_process(false)
world.player.position = Vector3(0, 0.1, 3.5)
world.player.camera.look_at(Vector3(0, 1.2, -3))
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
await physics_frame
var actor := Boss.new()
actor.configure(10)
actor.player = world.player
actor.navigation = world.navigation
actor.position = Vector3(0, 0.05, -3)
actor.rotation.y = PI
world.add_child(actor)
actor.set_physics_process(false)
world.boss = actor
world.wave = 10
var portal = load("res://scripts/boss_teleport.gd").new()
portal.position = actor.position
portal.materialized.connect(actor.materialize)
world.add_child(portal)
await create_timer(1.0).timeout
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("/tmp/zombie-boss-portal.png")
await create_timer(0.8).timeout
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("/tmp/zombie-boss-arrival.png")
await create_timer(1.8).timeout
actor.anim.play("hornetattack")
await create_timer(0.3).timeout
actor._fire_hornet()
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("/tmp/zombie-boss-combat.png")
print("SHOT: /tmp/zombie-boss-{portal,arrival,combat}.png")
world.queue_free()
await process_frame
quit()
+1
View File
@@ -0,0 +1 @@
uid://bifk8i65ile77
+223
View File
@@ -0,0 +1,223 @@
extends SceneTree
const Boss = preload("res://scripts/alien_grunt.gd")
const Hornet = preload("res://scripts/boss_hornet.gd")
const Main = preload("res://scripts/main.gd")
var checks := 0
var failures := 0
var killed_events := 0
func _initialize() -> void:
call_deferred("run_test")
func check(ok: bool, label: String) -> void:
checks += 1
if not ok:
failures += 1
print("PASS: " if ok else "FAIL: ", label)
func make_world() -> Node3D:
var world = load("res://scenes/main.tscn").instantiate()
root.add_child(world)
world.player.hp = 100000
world.player.set_physics_process(false)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
return world
func target_body(world: Node3D, at: Vector3, size: Vector3) -> StaticBody3D:
var body := StaticBody3D.new()
body.position = at
var collider := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = size
collider.shape = shape
body.add_child(collider)
world.add_child(body)
return body
func profile_snapshot() -> String:
if not FileAccess.file_exists(Main.Profile.PATH):
return "ABSENT"
return FileAccess.get_sha256(Main.Profile.PATH)
func run_test() -> void:
var profile_before := profile_snapshot()
check(not Main.is_boss_wave(0) and not Main.is_boss_wave(9) and Main.is_boss_wave(10)
and not Main.is_boss_wave(11) and Main.is_boss_wave(20), "boss solo ogni dieci ondate")
var a := Boss.stats_for_wave(10)
var b := Boss.stats_for_wave(20)
var c := Boss.stats_for_wave(30)
check(a.rank == 1 and b.rank == 2 and c.rank == 3 and a.hp == 800 and b.hp == 1200
and c.hp == 1600, "grado e salute crescono a ogni comparsa")
check(a.melee < b.melee and b.melee < c.melee and a.ranged < b.ranged
and b.ranged < c.ranged and a.speed < b.speed and b.interval < a.interval,
"danni, velocita' e cadenza crescono")
var late := Boss.stats_for_wave(10000)
check(late.speed <= 3.4 and late.interval >= 1.3, "mobilita' e cadenza hanno limiti giocabili")
var world = make_world()
await physics_frame
await physics_frame
check(world.zombies_for_wave(9) == 22 and world.zombies_for_wave(10) == 0
and world.zombies_for_wave(11) == 26, "ondata boss sostituisce gli zombie, non si somma")
for i in range(16):
var at: Vector3 = world._boss_spawn_position()
check(at.is_finite() and at.distance_to(world.player.global_position) >= 5,
"portale libero e distante: estrazione %d" % (i + 1))
# Real 9 -> 10 transition: finish all 22 ordinary enemies before the boss.
world.wave = 8
world.start_game("Test", false)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
check(world.wave == 9 and world.boss == null and world.spawning,
"ondata 9 ancora ordinaria")
await create_timer(18.0).timeout
check(world.alive == 22 and not world.spawning, "ondata 9 completa: 22 zombie, nessun boss")
for zombie in get_nodes_in_group("live_zombies"):
zombie.take_damage(1000000, zombie.global_position.y + 0.9)
await create_timer(3.2).timeout
var actor = world.boss
var score_before_boss: int = world.score
check(world.wave == 10 and world.alive == 1 and not world.spawning
and get_nodes_in_group("live_zombies").size() == 1
and actor.is_in_group("bosses"), "ondata 10: esattamente un boss e nessuno zombie ordinario")
check(actor.entering and not actor.model.visible and actor.collision_layer == 0,
"preavviso teletrasporto: boss non ancora attivo")
var hp_before: float = actor.hp
actor.take_damage(100, actor.position.y + 1)
world._check_wave()
check(actor.hp == hp_before and not world.wave_pending, "invulnerabile durante arrivo; niente salto ondata")
await create_timer(1.65).timeout
actor.set_physics_process(false)
check(not actor.entering and actor.model.visible and actor.collision_layer == 4,
"teletrasporto materializza modello e hitbox")
check(actor.anim.has_animation("walk") and actor.anim.has_animation("hornetattack")
and actor.anim.has_animation("mattack2") and actor.anim.has_animation("diesimple"),
"animazioni di combattimento e morte originali presenti")
var surfaces := 0
var textured := true
for mesh in actor.model.find_children("*", "MeshInstance3D", true, false):
for i in mesh.mesh.get_surface_count():
var material = mesh.mesh.surface_get_material(i)
textured = textured and material is StandardMaterial3D and material.albedo_texture != null
surfaces += 1
check(textured and surfaces > 0, "modello originale con texture incorporate, scena nativa")
# Stable open lane, actual hitscan and grenade blast (same live_zombies interface).
actor.position = Vector3(0, 0.05, -3)
world.player.position = Vector3(0, 0.1, 2)
world.player.camera.look_at(actor.global_position + Vector3.UP * 1.2)
await physics_frame
await physics_frame
hp_before = actor.hp
world.player.shoot()
check(actor.hp < hp_before, "fucile: il boss riceve i colpi tramite la normale hitbox")
var grenade = load("res://scripts/grenade.gd").new()
grenade.thrower = world.player
grenade.position = actor.position + Vector3(0, 0.4, 1)
world.add_child(grenade)
grenade.freeze = true
hp_before = actor.hp
grenade._request_detonation(grenade.global_position)
await process_frame
await process_frame
check(actor.hp < hp_before, "granata: il boss riceve il danno esplosivo")
world.hud.update_boss(actor)
check(world.hud.boss_panel.visible and world.hud.boss_health.value == actor.hp
and world.hud.boss_health.max_value == actor.max_hp, "barra vita del boss segue i danni")
# Real hornet flight towards player, with a separate obstruction regression.
var player_hp: float = world.player.hp
actor._fire_hornet()
check(get_nodes_in_group("boss_projectiles").size() == 1, "attacco alieno crea un proiettile visibile")
await create_timer(0.8).timeout
check(world.player.hp < player_hp, "proiettile alieno colpisce e danneggia il giocatore")
var wall := target_body(world, Vector3(0, 1.4, -0.5), Vector3(3, 3, 0.15))
await physics_frame
await physics_frame
player_hp = world.player.hp
actor._fire_hornet()
await create_timer(0.8).timeout
check(world.player.hp == player_hp and get_nodes_in_group("boss_projectiles").is_empty(),
"parete ferma i proiettili: niente colpi attraverso gli oggetti")
wall.queue_free()
await process_frame
# Melee cannot reach through the existing desk.
world.player.position = Vector3(-5, 0.1, -3.2)
actor.position = Vector3(-5, 0.05, -4.8)
await physics_frame
await physics_frame
player_hp = world.player.hp
actor._attack()
check(world.player.hp == player_hp, "corpo a corpo bloccato dalla scrivania")
actor.position = Vector3(0, 0.05, -1.5)
world.player.position = Vector3(0, 0.1, 0)
await physics_frame
await physics_frame
actor._attack()
check(world.player.hp < player_hp, "corpo a corpo infligge danno a distanza utile")
# AI pathfinding actually moves around an office desk, without teleport tricks.
var routes := [
[Vector3(-5, 0.05, -6.5), Vector3(-5, 0.1, 0)],
[Vector3(5, 0.05, 1.5), Vector3(5, 0.1, 7.5)],
[Vector3(-6, 0.05, 4), Vector3(-2, 0.1, 4)],
[Vector3(6.8, 0.05, -6), Vector3(6.8, 0.1, -2)],
]
for route in routes:
world.player.position = route[1]
actor.position = route[0]
actor.cooldown = 100
actor.attack_lock = 0
actor._nav_started = false
actor._repath = 0
actor.set_physics_process(true)
var reached := false
for i in range(650):
await physics_frame
if actor.position.distance_to(world.player.position) < 2.3:
reached = true
break
actor.set_physics_process(false)
check(reached, "boss aggira gli arredi: %s -> %s" % [route[0], route[1]])
# A queued burst must never fire after death.
actor._start_burst()
actor.killed.connect(func(_head): killed_events += 1)
actor.take_damage(1000000, actor.global_position.y + 1)
actor.take_damage(1000000, actor.global_position.y + 1)
check(killed_events == 1 and world.alive == 0 and world.score == score_before_boss + actor.reward
and world.wave_pending, "morte unica, ricompensa unica e completamento ondata boss")
world.hud.update_boss(actor)
check(not world.hud.boss_panel.visible and actor.collision_layer == 0
and not actor.is_in_group("live_zombies"), "morto: HUD nascosto, corpo non blocca e non conta vivo")
await create_timer(0.8).timeout
check(get_nodes_in_group("boss_projectiles").is_empty(), "raffica pendente annullata dalla morte")
await create_timer(2.5).timeout
check(world.wave == 11 and world.boss == null and world.alive > 0
and get_nodes_in_group("bosses").is_empty(), "10 -> 11: riprendono gli zombie normali una sola volta")
world.queue_free()
await process_frame
# Native sandbox: no start menu/network/profile; stops after the selected boss.
world = load("res://scenes/main.tscn").instantiate()
world.boss_simulation = true
world.simulation_wave = 20
root.add_child(world)
world.player.hp = 100000
world.player.set_physics_process(false)
await create_timer(1.8).timeout
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
actor = world.boss
actor.set_physics_process(false)
check(world.wave == 20 and actor.rank == 2 and actor.max_hp == 1200
and world.start_screen == null and world.leaderboard.disabled
and world.leaderboard._pending.is_empty(), "simulazione diretta grado 2, senza menu/classifica")
actor.take_damage(1000000, actor.position.y + 1)
await create_timer(3.2).timeout
check(world.simulation_finished and world.simulation_retry.visible and world.wave == 20
and world.alive == 0, "simulazione termina nella singola ondata; pulsante RIPROVA")
world.player.invulnerable = true
world.player.take_damage(1000000)
check(world.player.hp > 0, "opzione invulnerabile per prove native")
world.player.invulnerable = false
world.player.take_damage(1000000)
check(world.leaderboard._pending.is_empty() and not world.score_submitted
and world.profile.best == 0, "morte in simulazione non invia punteggi e non aggiorna il record")
check(profile_before == profile_snapshot(), "profilo persistente byte-identico dopo i test")
world.queue_free()
await process_frame
print(checks - failures, " controlli superati, ", failures, " falliti (", checks, " totali)")
quit(1 if failures else 0)
+1
View File
@@ -0,0 +1 @@
uid://dmkobdmpjqiai