From e9d2c64f85e451ab8b652db6c3e76c0e3992b55e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 2 May 2026 18:58:28 +0100 Subject: [PATCH] =?UTF-8?q?feat(combat):=20PlayerHP.affine=20=E2=80=94=20s?= =?UTF-8?q?econd=20.res=E2=86=92.affine=20translation=20(952B=20WASM)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translates src/app/combat/PlayerHP.res (88 lines, mutable record with six fields) into src/app/combat/PlayerHP.affine (144 lines, aspect-decomposed into HP / IFrames / Knockback / Player / Pos / Velocity / UpdateResult). Compiles via the Wave 0 containerized toolchain (./scripts/affinescript-host-shim.sh) to a 952-byte WASM MVP module. Two v0.1.0 expedients applied, both documented in the file header: - Float → Int with HP in tenths and timers in milliseconds (same as Hitbox.affine). - Immutable returning-new style instead of `mut Player` field assignment (the in-body assignment idiom for struct fields through a mut borrow is not demonstrated by any v0.1.0 example). The full re-decomposition rationale lives at: affinescript/docs/guides/lessons/migrations/idaptik-player-hp.adoc Second compiled .res→.affine translation in the migration (after Hitbox). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/combat/PlayerHP.affine | 141 +++++++++++++++++++++++++++++++++ src/app/combat/PlayerHP.wasm | Bin 0 -> 952 bytes 2 files changed, 141 insertions(+) create mode 100644 src/app/combat/PlayerHP.affine create mode 100644 src/app/combat/PlayerHP.wasm 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 0000000000000000000000000000000000000000..994c105a60ad27ea113d9af990cd88c35a8617bc GIT binary patch literal 952 zcmZWoy^hmB5T04@uGgL*TwOg;D6T@HNky~m3GoWKz&Sb}mmkFu5fYMl6lz34#Umgd zg~woKH#y&dWzTr#`@B2fXB(FN8UX-b)3qJsz~LJHxlHP!LSs0S6KH05aa{J@qTeic z{q6R!*zMboU3+{LRyT{|zB{x41=VN@>wFoi+9J3D0evY;#xH@&8vVLmZ}*=JR0#D? zS38KmktBXM8vm3Ue+={-7#OeYz~C5(KBF7rS04?-{oNF2FoQU`djV6!CKW-8f(ypE zft)NCdY^HtxMdqbdvsA&{WyKer49y?bOkwe%2GR-l{5qnP0|$<%|Oyn zamB8|d*xS6Qdl)c6;v8L(JzA}vpz_Xi5`@tc||>mqrwQXyNEqAv>X}56UHb&>^&pN zxrp$_4x=^VMt&tJi4R%@E+4rkR*YM~C@?Zpwv$OPI$uN8t_E@rEDH)qh_zIng{{jT`X6tZqxSf_ucOBa(mpg`~J4u)%v5! c1z=JvQnV?$Q`butPMK54sgjgwx`aOb7ueQx^8f$< literal 0 HcmV?d00001