Wave escalation and the Snippest boss

Escalation (EnemySpawner exports, all tunable):
- Wave size grows +1 enemy per wave, capped at 20
- Enemy health +15% and damage +10% per wave, applied identically on
  every peer via the spawn RPC before _ready (so hitbox stats and
  current_health pick them up)

Boss milestone every 5th wave: the Snippest - the basic lobster at
5x scale and 5x combat stats (50 HP, 25 damage, 5x knockback and
reach). Movement deliberately not 5x (1.2x - it lumbers). Pays 250
gold and always drops a health orb. Wave escalation multipliers stack
on top, so later bosses keep scaling.

Boss scene instances basic_enemy.tscn with the BossLobster script;
visual/collision children are scaled uniformly in code (not the
CharacterBody3D root, not shared shape resources).
This commit is contained in:
2026-07-02 22:27:14 +01:00
parent fb10f7b042
commit 8f28304d0f
4 changed files with 105 additions and 6 deletions
+35
View File
@@ -0,0 +1,35 @@
extends BasicEnemy
class_name BossLobster
## The Snippest itself: a lobster five times the size with five times the stats.
## Spawned by the EnemySpawner on boss milestone waves.
const BOSS_MULTIPLIER := 5.0
func _ready():
# 5x combat stats - applied BEFORE super._ready() so the hitbox
# picks them up via set_stats(attack_damage, attack_knockback)
max_health *= BOSS_MULTIPLIER
attack_damage *= BOSS_MULTIPLIER
attack_knockback *= BOSS_MULTIPLIER
attack_range *= BOSS_MULTIPLIER # A 5x lobster has 5x reach
# Deliberately NOT 5x: movement (17.5 speed would be inescapable).
# Big things lumber. Tune here if the boss should be faster.
move_speed *= 1.2
# Boss rewards: jackpot gold, guaranteed health orb
gold_reward = 250
health_orb_drop_chance = 1.0
super._ready()
_apply_boss_scale()
## Uniformly scale the visual and collision children 5x around the root.
## Child-node scaling (not root CharacterBody3D scale, not shared shape
## resources) keeps physics sane and other lobsters unaffected.
func _apply_boss_scale():
for child_name in ["Mesh", "CollisionShape3D", "HurtBox", "HitBox"]:
var child = get_node_or_null(child_name)
if child:
child.transform = child.transform.scaled(Vector3.ONE * BOSS_MULTIPLIER)
+60 -5
View File
@@ -25,6 +25,14 @@ signal all_enemies_defeated()
@export_category("Enemy Pool")
@export var enemy_scenes: Array[PackedScene] = [] ## List of enemy scenes to spawn from
@export_category("Escalation")
@export var wave_count_growth: float = 1.0 ## Extra enemies added per wave
@export var max_enemies_per_wave: int = 20 ## Hard cap on wave size
@export var health_growth_per_wave: float = 0.15 ## +15% enemy health per wave
@export var damage_growth_per_wave: float = 0.10 ## +10% enemy damage per wave
@export var boss_wave_interval: int = 5 ## Every Nth wave is a boss wave (0 = never)
@export var boss_scene: PackedScene = null ## The Snippest itself
## Wave tracking
var current_wave: int = 0
var active_enemies: Array[Node] = []
@@ -74,9 +82,46 @@ func start_wave():
# Broadcast wave number to every peer's GameState (for HUD + run stats)
GameState.server_sync_wave(current_wave)
# Spawn enemies
for i in range(enemies_per_wave):
_spawn_enemy(i, enemies_per_wave)
# Boss milestone waves field the Snippest instead of a normal wave
if _is_boss_wave(current_wave):
print("[EnemySpawner] BOSS WAVE ", current_wave)
_spawn_boss()
else:
var count = _wave_enemy_count(current_wave)
for i in range(count):
_spawn_enemy(i, count)
## Every Nth wave, if a boss scene is configured
func _is_boss_wave(wave: int) -> bool:
return boss_wave_interval > 0 and boss_scene != null and wave % boss_wave_interval == 0
## Wave size grows linearly, capped
func _wave_enemy_count(wave: int) -> int:
return mini(enemies_per_wave + int((wave - 1) * wave_count_growth), max_enemies_per_wave)
func _wave_health_mult(wave: int) -> float:
return 1.0 + (wave - 1) * health_growth_per_wave
func _wave_damage_mult(wave: int) -> float:
return 1.0 + (wave - 1) * damage_growth_per_wave
## Spawn the boss at a random point on the spawn circle (server only)
func _spawn_boss():
if not multiplayer.is_server():
return
_enemy_id_counter += 1
var boss_name = "Boss_" + str(current_wave) + "_" + str(_enemy_id_counter)
var angle = randf() * TAU
var spawn_pos = global_position + Vector3(
cos(angle) * spawn_radius,
spawn_height,
sin(angle) * spawn_radius
)
rpc("_spawn_enemy_on_client", boss_name, spawn_pos, boss_scene.resource_path,
_wave_health_mult(current_wave), _wave_damage_mult(current_wave))
## Spawn a single enemy at a position around the circle
func _spawn_enemy(index: int, total: int):
@@ -108,11 +153,13 @@ func _spawn_enemy(index: int, total: int):
# Spawn on all clients via RPC (call_local will spawn on server too)
var enemy_scene_path = enemy_scene.resource_path
rpc("_spawn_enemy_on_client", enemy_name, spawn_pos, enemy_scene_path)
rpc("_spawn_enemy_on_client", enemy_name, spawn_pos, enemy_scene_path,
_wave_health_mult(current_wave), _wave_damage_mult(current_wave))
## RPC to spawn enemy on all clients (including server via call_local)
@rpc("any_peer", "call_local", "reliable")
func _spawn_enemy_on_client(enemy_name: String, spawn_pos: Vector3, scene_path: String):
func _spawn_enemy_on_client(enemy_name: String, spawn_pos: Vector3, scene_path: String,
health_mult: float = 1.0, damage_mult: float = 1.0):
# Load the enemy scene
var enemy_scene = load(scene_path)
if not enemy_scene:
@@ -124,6 +171,14 @@ func _spawn_enemy_on_client(enemy_name: String, spawn_pos: Vector3, scene_path:
enemy.name = enemy_name
enemy.position = spawn_pos
# Wave escalation - set BEFORE add_child so _ready picks the values up
# (current_health = max_health in BaseUnit, hitbox set_stats in BasicEnemy).
# Applied identically on every peer; the boss's own 5x stacks on top.
if health_mult != 1.0 and "max_health" in enemy:
enemy.max_health *= health_mult
if damage_mult != 1.0 and "attack_damage" in enemy:
enemy.attack_damage *= damage_mult
# Find enemies container
var level = get_tree().get_current_scene()
var enemies_container = null