cb2c1e7840
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>
91 lines
3.0 KiB
GDScript
91 lines
3.0 KiB
GDScript
class_name Progression
|
|
extends RefCounted
|
|
## Levels, experience and what a level is worth.
|
|
##
|
|
## Pure functions over integers, so the curve can be tuned and tested without a
|
|
## server, a character, or a running game. The server is the only thing that
|
|
## ever calls the mutating side of this; a client is told its level and takes
|
|
## the server's word for it.
|
|
|
|
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. 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
|
|
## level and some change, not exactly on the line.
|
|
const BASE_XP := 200
|
|
## How sharply the requirement grows. 1.0 would be linear; this makes level 15 a
|
|
## long-term goal without making level 2 feel far away.
|
|
const XP_CURVE := 1.35
|
|
|
|
## Experience awarded per kill, by what died.
|
|
const XP_DRIFTER := 10
|
|
const XP_TURRET := 12
|
|
const XP_STALKER := 8
|
|
const XP_BOSS := 200
|
|
|
|
|
|
## Experience needed to go from [param level] to the next one. Zero at the cap,
|
|
## which is what makes "already maxed" a total-ordering question rather than a
|
|
## special case at every call site.
|
|
static func xp_to_next(level: int) -> int:
|
|
if level >= MAX_LEVEL:
|
|
return 0
|
|
return int(round(float(BASE_XP) * pow(float(level), XP_CURVE)))
|
|
|
|
|
|
## Total experience to reach [param level] from level 1.
|
|
static func total_xp_for_level(level: int) -> int:
|
|
var total := 0
|
|
for l in range(START_LEVEL, mini(level, MAX_LEVEL)):
|
|
total += xp_to_next(l)
|
|
return total
|
|
|
|
|
|
## The level a given lifetime experience total corresponds to.
|
|
static func level_for_xp(total_xp: int) -> int:
|
|
var level := START_LEVEL
|
|
var spent := 0
|
|
while level < MAX_LEVEL:
|
|
var need := xp_to_next(level)
|
|
if total_xp - spent < need:
|
|
break
|
|
spent += need
|
|
level += 1
|
|
return level
|
|
|
|
|
|
## Progress through the current level, 0..1. Returns 1.0 at the cap so a
|
|
## progress bar reads as full rather than empty.
|
|
static func level_progress(total_xp: int) -> float:
|
|
var level := level_for_xp(total_xp)
|
|
if level >= MAX_LEVEL:
|
|
return 1.0
|
|
var into := total_xp - total_xp_for_level(level)
|
|
var need := xp_to_next(level)
|
|
return clampf(float(into) / float(maxi(need, 1)), 0.0, 1.0)
|
|
|
|
|
|
static func max_hp_for_level(level: int) -> int:
|
|
var l := clampi(level, START_LEVEL, MAX_LEVEL)
|
|
return SimConfig.PLAYER_MAX_HP + (l - START_LEVEL) * HP_PER_LEVEL
|
|
|
|
|
|
## Experience for killing an enemy, by content id. Unknown ids award nothing
|
|
## rather than a default, so a new enemy that nobody scored is obvious in play
|
|
## instead of quietly paying out.
|
|
static func xp_for_enemy(id: StringName) -> int:
|
|
match id:
|
|
Content.ENEMY_DRIFTER: return XP_DRIFTER
|
|
Content.ENEMY_TURRET: return XP_TURRET
|
|
Content.ENEMY_STALKER: return XP_STALKER
|
|
return 0
|
|
|
|
|
|
static func xp_for_boss(_id: StringName) -> int:
|
|
return XP_BOSS
|