From 8f28304d0f115b3df503bac13edc630300544aeb Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 2 Jul 2026 22:27:14 +0100 Subject: [PATCH] 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). --- level/scenes/enemies/boss_lobster.tscn | 7 +++ level/scenes/enemy_spawner.tscn | 4 +- level/scripts/boss_lobster.gd | 35 ++++++++++++++ level/scripts/enemy_spawner.gd | 65 ++++++++++++++++++++++++-- 4 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 level/scenes/enemies/boss_lobster.tscn create mode 100644 level/scripts/boss_lobster.gd diff --git a/level/scenes/enemies/boss_lobster.tscn b/level/scenes/enemies/boss_lobster.tscn new file mode 100644 index 0000000..c48d210 --- /dev/null +++ b/level/scenes/enemies/boss_lobster.tscn @@ -0,0 +1,7 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="PackedScene" uid="uid://byknup31d2b53" path="res://level/scenes/enemies/basic_enemy.tscn" id="1_base"] +[ext_resource type="Script" path="res://level/scripts/boss_lobster.gd" id="2_boss_script"] + +[node name="BossLobster" instance=ExtResource("1_base")] +script = ExtResource("2_boss_script") diff --git a/level/scenes/enemy_spawner.tscn b/level/scenes/enemy_spawner.tscn index cd7fec9..ef1d99b 100644 --- a/level/scenes/enemy_spawner.tscn +++ b/level/scenes/enemy_spawner.tscn @@ -1,7 +1,8 @@ -[gd_scene load_steps=4 format=3 uid="uid://blm8lav3xh2yw"] +[gd_scene load_steps=5 format=3 uid="uid://blm8lav3xh2yw"] [ext_resource type="Script" path="res://level/scripts/enemy_spawner.gd" id="1_spawner"] [ext_resource type="PackedScene" uid="uid://byknup31d2b53" path="res://level/scenes/enemies/basic_enemy.tscn" id="2_basic_enemy"] +[ext_resource type="PackedScene" path="res://level/scenes/enemies/boss_lobster.tscn" id="3_boss"] [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_indicator"] albedo_color = Color(1, 0.5, 0, 0.3) @@ -17,6 +18,7 @@ enemies_per_wave = 10 auto_start_next_wave = false wave_delay = 5.0 enemy_scenes = Array[PackedScene]([ExtResource("2_basic_enemy")]) +boss_scene = ExtResource("3_boss") [node name="SpawnRadiusIndicator" type="MeshInstance3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.1, 0) diff --git a/level/scripts/boss_lobster.gd b/level/scripts/boss_lobster.gd new file mode 100644 index 0000000..cebeb0f --- /dev/null +++ b/level/scripts/boss_lobster.gd @@ -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) diff --git a/level/scripts/enemy_spawner.gd b/level/scripts/enemy_spawner.gd index 03ce491..e88d221 100644 --- a/level/scripts/enemy_spawner.gd +++ b/level/scripts/enemy_spawner.gd @@ -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