diff --git a/src/app/combat/PlayerHP.affine b/src/app/combat/PlayerHP.affine new file mode 100644 index 00000000..929a379a --- /dev/null +++ b/src/app/combat/PlayerHP.affine @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// PlayerHP — health, invincibility frames, knockback. +// +// Translated from src/app/combat/PlayerHP.res (Wave 3 second proof, 2026-05-02). +// See lesson at affinescript/docs/guides/lessons/migrations/idaptik-player-hp.adoc +// for the re-decomposition rationale. +// +// === Re-decompositions vs the ReScript original === +// 1. The single mutable record with six fields is fissioned into three aspect +// structs (HP, IFrames, Knockback) composed in a parent Player. +// 2. Same-typed labelled args (~fromX, ~playerX) become a Pos struct. +// 3. Module-level tuning constants (Damage.roboDogContact, Knockback.speed, +// etc.) become no-arg fns rather than `static let` — v0.1.0 has no module +// const surface I have verified; using fns avoids the question and is +// inlinable by the compiler. +// +// === v0.1.0 EXPEDIENT: Int instead of Float === +// Float arithmetic is unsupported by v0.1.0's operators (see Hitbox.affine +// and the hitbox lesson). HP is in tenths (max baseline 1000 = 100.0 HP); +// timers in milliseconds (1000ms = 1.0s). Damage values rescale by 10. +// +// === v0.1.0 EXPEDIENT: immutable returning-new instead of `mut Player` === +// The lesson's design specifies `take_damage(p: mut Player, ...)`. v0.1.0 +// has `mut` parameters (see examples/ownership.affine) but the assignment +// surface for mutating struct fields through a `mut` borrow is not yet +// verified. This version returns a fresh Player; rewrite when the mut + +// assignment idiom is confirmed. + +struct HP { + current: Int, + max: Int +} + +struct IFrames { + remaining_ms: Int +} + +struct Knockback { + vel_x: Int, + vel_y: Int, + remaining_ms: Int +} + +struct Player { + hp: HP, + iframes: IFrames, + knockback: Knockback +} + +struct Pos { + x: Int +} + +struct Velocity { + x: Int, + y: Int +} + +struct UpdateResult { + player: Player, + velocity: Velocity +} + +// Tuning constants as no-arg fns. +fn knockback_speed() -> Int { 200 } +fn knockback_duration_ms() -> Int { 300 } +fn iframe_duration_ms() -> Int { 1000 } +fn knockback_pop_y() -> Int { 0 - 80 } + +fn max(a: Int, b: Int) -> Int { + if a > b { a } else { b } +} + +// CON is a 0-200 scaling factor; baseline 100 → 1000 (= 100.0 HP in tenths). +fn make(con: Int) -> Player { + let max_hp = 800 + 2 * con; + { + hp: { current: max_hp, max: max_hp }, + iframes: { remaining_ms: 0 }, + knockback: { vel_x: 0, vel_y: 0, remaining_ms: 0 } + } +} + +fn is_invincible(p: Player) -> Bool { + p.iframes.remaining_ms > 0 +} + +fn is_alive(p: Player) -> Bool { + p.hp.current > 0 +} + +// take_damage returns a fresh Player (see header note on mut). +fn take_damage(p: Player, amount: Int, source: Pos, player: Pos) -> Player { + if is_invincible(p) { + p + } else { + let new_current = max(0, p.hp.current - amount); + let direction = if player.x >= source.x { 1 } else { 0 - 1 }; + { + hp: { current: new_current, max: p.hp.max }, + iframes: { remaining_ms: iframe_duration_ms() }, + knockback: { + vel_x: direction * knockback_speed(), + vel_y: knockback_pop_y(), + remaining_ms: knockback_duration_ms() + } + } + } +} + +fn update(p: Player, dt_ms: Int) -> UpdateResult { + let new_iframe = if p.iframes.remaining_ms > 0 { + max(0, p.iframes.remaining_ms - dt_ms) + } else { + 0 + }; + let new_kb_remaining = if p.knockback.remaining_ms > 0 { + max(0, p.knockback.remaining_ms - dt_ms) + } else { + 0 + }; + let emit_velocity = if p.knockback.remaining_ms > 0 { + { x: p.knockback.vel_x, y: p.knockback.vel_y } + } else { + { x: 0, y: 0 } + }; + let new_kb = if new_kb_remaining <= 0 { + { vel_x: 0, vel_y: 0, remaining_ms: 0 } + } else { + { vel_x: p.knockback.vel_x, vel_y: p.knockback.vel_y, remaining_ms: new_kb_remaining } + }; + { + player: { + hp: p.hp, + iframes: { remaining_ms: new_iframe }, + knockback: new_kb + }, + velocity: emit_velocity + } +} diff --git a/src/app/combat/PlayerHP.wasm b/src/app/combat/PlayerHP.wasm new file mode 100644 index 00000000..994c105a Binary files /dev/null and b/src/app/combat/PlayerHP.wasm differ