Scale health and damage by ten so percentages have somewhere to land
A shot is 60, a fresh character has 1000, the Warden has 36000. Enemy health, boss health and every emitter's damage were scaled with them, so every ratio is unchanged and time to kill is exactly what it was -- tests/unit/test_content now pins shots-to-kill per enemy and hits-to-kill a player so a careless edit to either side shows up as a number a designer recognises. The reason is rounding rather than balance. Damage is an integer, and at a base of 6 the +5% every upgrade carries computed to 6.3 and rounded straight back to 6: a player took their first upgrade, was told it made them stronger, and nothing happened. diag_upgrades used to print "damage matches the formula (6 -> 6)"; it now prints (60 -> 63). The boss's per-phase armour multiplier had the same problem, turning 1.15 into an effective 1.17. The practice dummy stopped relying on a huge health pool at the same time. Its old 100000 was already past the u16 the snapshot sends enemy health in, and once a shot did 60 a patient player could have destroyed the hub's only practice target for everyone until the next restart. EnemyDef.indestructible says what was actually meant, and a test asserts nothing else uses it. check.sh clean, 364 tests, SMOKE PASS, all four diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,11 @@ enum Move {
|
||||
@export var emitters: Array[BulletEmitter] = []
|
||||
## The emitter timeline wraps at this many ticks.
|
||||
@export var pattern_loop_ticks: int = 240
|
||||
## Never takes damage. Exists for the hub's practice target, whose whole job is
|
||||
## to still be there tomorrow -- expressing that as a flag rather than as a
|
||||
## large health pool means it cannot be worn down by a patient player, and
|
||||
## keeps its health inside the u16 the snapshot sends.
|
||||
@export var indestructible: bool = false
|
||||
## What this enemy may leave behind. Rolled once per entry on death, against
|
||||
## the world's own RNG. Empty for anything that should drop nothing.
|
||||
@export var loot: Array[LootDrop] = []
|
||||
|
||||
+25
-19
@@ -43,7 +43,7 @@ static func drifter() -> EnemyDef:
|
||||
var d := EnemyDef.new()
|
||||
d.id = ENEMY_DRIFTER
|
||||
d.display_name = "Drifter"
|
||||
d.max_hp = 40
|
||||
d.max_hp = 400
|
||||
d.radius = 14.0
|
||||
d.move = EnemyDef.Move.DRIFT
|
||||
d.speed = 55.0
|
||||
@@ -57,7 +57,7 @@ static func drifter() -> EnemyDef:
|
||||
fan.spread_deg = 20.0
|
||||
fan.speed = 150.0
|
||||
fan.radius = 6.0
|
||||
fan.damage = 10
|
||||
fan.damage = 100
|
||||
fan.lifetime = 240
|
||||
fan.kind = SimConfig.KIND_ORB
|
||||
d.emitters = [fan]
|
||||
@@ -70,7 +70,7 @@ static func turret() -> EnemyDef:
|
||||
var d := EnemyDef.new()
|
||||
d.id = ENEMY_TURRET
|
||||
d.display_name = "Turret"
|
||||
d.max_hp = 70
|
||||
d.max_hp = 700
|
||||
d.radius = 16.0
|
||||
d.move = EnemyDef.Move.STATIC
|
||||
d.speed = 0.0
|
||||
@@ -84,7 +84,7 @@ static func turret() -> EnemyDef:
|
||||
ring.spin_per_shot_deg = 18.0
|
||||
ring.speed = 130.0
|
||||
ring.radius = 7.0
|
||||
ring.damage = 12
|
||||
ring.damage = 120
|
||||
ring.lifetime = 300
|
||||
ring.kind = SimConfig.KIND_ORB
|
||||
d.emitters = [ring]
|
||||
@@ -104,7 +104,7 @@ static func stalker() -> EnemyDef:
|
||||
var d := EnemyDef.new()
|
||||
d.id = ENEMY_STALKER
|
||||
d.display_name = "Stalker"
|
||||
d.max_hp = 30
|
||||
d.max_hp = 300
|
||||
d.radius = 12.0
|
||||
d.move = EnemyDef.Move.APPROACH
|
||||
d.speed = 95.0
|
||||
@@ -119,7 +119,7 @@ static func stalker() -> EnemyDef:
|
||||
lunge.spread_deg = 62.0
|
||||
lunge.speed = 260.0
|
||||
lunge.radius = 6.0
|
||||
lunge.damage = 14
|
||||
lunge.damage = 140
|
||||
# 18 ticks at 260 u/s is about 78px of reach -- shorter than the muzzle-to-
|
||||
# player distance at any range you would call "not point blank".
|
||||
lunge.lifetime = 18
|
||||
@@ -130,12 +130,18 @@ static func stalker() -> EnemyDef:
|
||||
return d
|
||||
|
||||
|
||||
## Lobby target dummy: inert, tough, so players can feel out the gun.
|
||||
## Lobby target dummy: inert, indestructible, so players can feel out the gun.
|
||||
##
|
||||
## Indestructible by flag rather than by a huge health pool. The old 100000 was
|
||||
## already past the u16 the snapshot sends enemy health in, and after the x10
|
||||
## rescale a determined player could have destroyed the hub's only practice
|
||||
## target for everyone until the server restarted.
|
||||
static func dummy() -> EnemyDef:
|
||||
var d := EnemyDef.new()
|
||||
d.id = ENEMY_DUMMY
|
||||
d.display_name = "Target Dummy"
|
||||
d.max_hp = 100000
|
||||
d.max_hp = 1000
|
||||
d.indestructible = true
|
||||
d.radius = 20.0
|
||||
d.move = EnemyDef.Move.STATIC
|
||||
d.visual = 3
|
||||
@@ -151,7 +157,7 @@ static func warden() -> BossDef:
|
||||
var b := BossDef.new()
|
||||
b.id = BOSS_WARDEN
|
||||
b.display_name = "Warden of the Fold"
|
||||
b.max_hp = 3600
|
||||
b.max_hp = 36000
|
||||
b.radius = 42.0
|
||||
b.stationary = true
|
||||
b.spawn_pos = Vector2(0.0, -150.0)
|
||||
@@ -182,7 +188,7 @@ static func _warden_p1() -> BossPhase:
|
||||
ring.spin_per_shot_deg = 9.0
|
||||
ring.speed = 135.0
|
||||
ring.radius = 8.0
|
||||
ring.damage = 14
|
||||
ring.damage = 140
|
||||
ring.lifetime = 420
|
||||
ring.muzzle_offset = 46.0
|
||||
|
||||
@@ -193,7 +199,7 @@ static func _warden_p1() -> BossPhase:
|
||||
fan.spread_deg = 26.0
|
||||
fan.speed = 210.0
|
||||
fan.radius = 6.0
|
||||
fan.damage = 12
|
||||
fan.damage = 120
|
||||
fan.kind = SimConfig.KIND_NEEDLE
|
||||
fan.muzzle_offset = 46.0
|
||||
|
||||
@@ -216,7 +222,7 @@ static func _warden_p2() -> BossPhase:
|
||||
spiral.spin_per_shot_deg = 23.0
|
||||
spiral.speed = 120.0
|
||||
spiral.radius = 7.0
|
||||
spiral.damage = 12
|
||||
spiral.damage = 120
|
||||
spiral.lifetime = 480
|
||||
spiral.muzzle_offset = 46.0
|
||||
|
||||
@@ -229,7 +235,7 @@ static func _warden_p2() -> BossPhase:
|
||||
wall.gap_step = 7
|
||||
wall.speed = 175.0
|
||||
wall.radius = 8.0
|
||||
wall.damage = 16
|
||||
wall.damage = 160
|
||||
wall.lifetime = 300
|
||||
wall.kind = SimConfig.KIND_HEAVY
|
||||
|
||||
@@ -256,7 +262,7 @@ static func _warden_p3() -> BossPhase:
|
||||
arms.sweep_period = 5.0
|
||||
arms.speed = 165.0
|
||||
arms.radius = 7.0
|
||||
arms.damage = 13
|
||||
arms.damage = 130
|
||||
arms.lifetime = 400
|
||||
|
||||
var fan := AimedSpreadEmitter.new()
|
||||
@@ -268,7 +274,7 @@ static func _warden_p3() -> BossPhase:
|
||||
fan.speed = 195.0
|
||||
fan.edge_speed_bonus = 0.25
|
||||
fan.radius = 6.0
|
||||
fan.damage = 12
|
||||
fan.damage = 120
|
||||
fan.kind = SimConfig.KIND_NEEDLE
|
||||
|
||||
var counter_ring := RingEmitter.new()
|
||||
@@ -279,7 +285,7 @@ static func _warden_p3() -> BossPhase:
|
||||
counter_ring.spin_per_shot_deg = -14.0
|
||||
counter_ring.speed = 105.0
|
||||
counter_ring.radius = 7.0
|
||||
counter_ring.damage = 12
|
||||
counter_ring.damage = 120
|
||||
counter_ring.lifetime = 420
|
||||
|
||||
p.emitters = [arms, fan, counter_ring]
|
||||
@@ -303,7 +309,7 @@ static func _warden_p4() -> BossPhase:
|
||||
curve.speed = 130.0
|
||||
curve.turn_deg = 0.55
|
||||
curve.radius = 7.0
|
||||
curve.damage = 15
|
||||
curve.damage = 150
|
||||
curve.lifetime = 400
|
||||
curve.muzzle_offset = 46.0
|
||||
|
||||
@@ -316,7 +322,7 @@ static func _warden_p4() -> BossPhase:
|
||||
walls.gap_step = 5
|
||||
walls.speed = 200.0
|
||||
walls.radius = 8.0
|
||||
walls.damage = 18
|
||||
walls.damage = 180
|
||||
walls.kind = SimConfig.KIND_HEAVY
|
||||
|
||||
var snipe := AimedSpreadEmitter.new()
|
||||
@@ -326,7 +332,7 @@ static func _warden_p4() -> BossPhase:
|
||||
snipe.spread_deg = 10.0
|
||||
snipe.speed = 300.0
|
||||
snipe.radius = 5.0
|
||||
snipe.damage = 16
|
||||
snipe.damage = 160
|
||||
snipe.kind = SimConfig.KIND_NEEDLE
|
||||
|
||||
p.emitters = [curve, walls, snipe]
|
||||
|
||||
@@ -35,12 +35,19 @@ const PLAYER_VISUAL_RADIUS := 13.0
|
||||
## the ship -- and on a remote client, the ship is also drawn a tick or two
|
||||
## ahead of the server, so this margin is what absorbs that too.
|
||||
const PLAYER_MUZZLE_OFFSET := PLAYER_VISUAL_RADIUS + 6.0
|
||||
const PLAYER_MAX_HP := 100
|
||||
## Health and damage are deliberately an order of magnitude larger than the
|
||||
## numbers they started as. Every ratio in the game is unchanged -- enemy
|
||||
## health, boss health and every emitter's damage were scaled with them -- but
|
||||
## percentages now have somewhere to land. At the old base of 6 damage, the +5%
|
||||
## that every upgrade carries computed to 6.3 and rounded straight back to 6,
|
||||
## so a player's first upgrade visibly did nothing at all. At 60 it is +3.
|
||||
## Do not "tidy" these back down without scaling content.gd with them.
|
||||
const PLAYER_MAX_HP := 1000
|
||||
const PLAYER_FIRE_COOLDOWN := 14 # ticks (~4.3 shots/sec)
|
||||
const PLAYER_BULLET_SPEED := 620.0
|
||||
const PLAYER_BULLET_RADIUS := 4.0
|
||||
const PLAYER_BULLET_LIFETIME := 90 # ticks
|
||||
const PLAYER_BULLET_DAMAGE := 6
|
||||
const PLAYER_BULLET_DAMAGE := 60
|
||||
## There are deliberately NO invulnerability frames after a hit. In a bullet
|
||||
## hell the wall of bullets IS the threat, and i-frames turn a dense pattern
|
||||
## into a single cheap hit -- you get punished for the first bullet and gifted
|
||||
|
||||
@@ -10,8 +10,9 @@ extends RefCounted
|
||||
const MAX_LEVEL := 15
|
||||
const START_LEVEL := 1
|
||||
## Hit points added per level gained. Level 1 is SimConfig.PLAYER_MAX_HP, so a
|
||||
## capped character has PLAYER_MAX_HP + 14 * this.
|
||||
const HP_PER_LEVEL := 10
|
||||
## capped character has PLAYER_MAX_HP + 14 * this. Scaled with everything else
|
||||
## -- see the note on SimConfig.PLAYER_MAX_HP.
|
||||
const HP_PER_LEVEL := 100
|
||||
|
||||
## Experience for the first level-up. The curve is tuned so one full clear of a
|
||||
## depth-1 dungeon lands a little past this -- the first run should end with a
|
||||
|
||||
@@ -746,6 +746,8 @@ func _damage_player(p: SimPlayer, amount: int) -> void:
|
||||
## from the snapshot anyway. Death is still announced either way, because that
|
||||
## is what the experience award is keyed on.
|
||||
func _damage_enemy(e: SimEnemy, amount: int, silent: bool = false) -> void:
|
||||
if e.def.indestructible:
|
||||
return
|
||||
e.hp = maxi(e.hp - amount, 0)
|
||||
if not silent:
|
||||
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
|
||||
|
||||
Reference in New Issue
Block a user