feat(controllers): #57 foundation — ludic.gameplay package + lever 5 (disable system)
Shared building blocks for the builtin gameplay controllers (#57): the ludic.gameplay source package (Cooldown timer, Stats + timed modifier stack, Faction table, Combat damage pipeline with cancel/mutable hooks), plus the compiler `disable system <esys_fn>` extensibility lever. Deterministic, integer-only; C-free bootstrap fixpoint holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
764a0296ce
commit
ed385efa7b
11 changed files with 18734 additions and 18157 deletions
3
changes/controllers-foundation.md
Normal file
3
changes/controllers-foundation.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
bump: minor
|
||||
type: feat
|
||||
Gameplay-controller foundation (#57) — the shared, cross-genre building blocks the builtin controllers stand on, shipped as the source package **ludic.gameplay**: a deterministic `Cooldown` frame timer (engine-ticked), a `Stats` attribute bundle with an unbounded timed **modifier stack** (`Stats.total` computes base+flat then percent on demand; expired modifiers self-despawn), a `Faction` friend/enemy/neutral relationship table (same-id-friendly / different-hostile by default), and a `Combat` damage pipeline whose `cancellable DamageAboutToApply` hook lets a game veto a hit *or rewrite the amount* (`Combat.set_amount`) and which emits `Damaged`/`Died`/`Healed`. Everything is integer-only so lockstep, replay and `world_save` snapshots hold. Also adds extensibility **lever 5** to the language: `disable system <esys_fn>` drops exactly one engine-owned system's tick at compile time, so a game can carry a well-known component but tick it with its own handler (byte-identical when nothing is disabled; the C-free bootstrap fixpoint is untouched). Example: `examples/library/gameplay_foundation.ludic`.
|
||||
82
examples/library/gameplay_foundation.ludic
Normal file
82
examples/library/gameplay_foundation.ludic
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# gameplay_foundation.ludic — exercises the ludic.gameplay foundation package
|
||||
# (#57): the Cooldown timer, the Stats + modifier stack, the Faction table and the
|
||||
# Combat damage pipeline (cancel + mutable hooks). Deterministic; a full run prints:
|
||||
# 2 0 1 5 24 0 2 1 15 85 7 1 1
|
||||
#
|
||||
# Build (from the repo root, so runtime/native resolves):
|
||||
# LUDIC_MODULES=packages ludicc --headless examples/library/gameplay_foundation.ludic
|
||||
program GameplayFoundation {
|
||||
import "ludic.gameplay/cooldown.ludic"
|
||||
import "ludic.gameplay/stats.ludic"
|
||||
import "ludic.gameplay/faction.ludic"
|
||||
import "ludic.gameplay/combat.ludic"
|
||||
|
||||
model Fighter { Stats, Cooldown, Faction }
|
||||
@OnSpawn(Fighter) handler FighterInit { }
|
||||
|
||||
# a game-supplied policy: while "enraged" is set, halve incoming damage by
|
||||
# rewriting the pending amount (lever 6, the mutable hook), and hard-veto any
|
||||
# hit over 100 (lever 4, cancel). Both are just listeners — no engine fork.
|
||||
var enraged: int = 0
|
||||
@On(DamageAboutToApply) handler Ward {
|
||||
if amount > 100 { cancel }
|
||||
if enraged == 1 { Combat.set_amount(amount / 2) }
|
||||
}
|
||||
|
||||
var deaths: int = 0
|
||||
@On(Died) handler Tally { deaths = deaths + 1 }
|
||||
|
||||
function tick_n(n: int) -> void { var i = 0; while i < n { tick_fixed(); i = i + 1 } }
|
||||
function bi(b: bool) -> int { if b { return 1 }; return 0 }
|
||||
|
||||
entry {
|
||||
let PS = World.prop_id("Stats")
|
||||
let fhp = World.field_id(PS, "hp")
|
||||
let fatk = World.field_id(PS, "atk")
|
||||
|
||||
# ---- Cooldown: engine-ticked frame timer -------------------------------
|
||||
spawn Fighter { Stats { hp: 30, max_hp: 30, def: 5 }, Faction { id: 1 } }
|
||||
let a = World.query_next(PS, 0)
|
||||
Cooldown.start(a, 3)
|
||||
tick_n(1)
|
||||
print(Cooldown.left(a)) # 2 — decremented once by esys_cooldown
|
||||
print(Cooldown.ready(a)) # 0 — still counting
|
||||
tick_n(2)
|
||||
print(Cooldown.ready(a)) # 1 — hit zero
|
||||
|
||||
# ---- Stats + modifier stack --------------------------------------------
|
||||
print(Stats.total(a, 2)) # 5 — base def, no modifiers
|
||||
World.set(a, PS, fatk, 10)
|
||||
Stats.modify(a, 1, 0, 6, 0) # +6 flat atk, permanent
|
||||
Stats.modify(a, 1, 1, 50, 0) # +50% atk, permanent
|
||||
print(Stats.total(a, 1)) # 24 — (10 + 6) * 150 / 100
|
||||
|
||||
# ---- Faction relationships ---------------------------------------------
|
||||
print(Faction.rel(1, 1)) # 0 — same faction is friendly
|
||||
print(Faction.rel(1, 2)) # 2 — different is hostile by default
|
||||
Faction.set(1, 2, 1) # make 1 and 2 neutral
|
||||
print(Faction.rel(1, 2)) # 1
|
||||
|
||||
# ---- Combat pipeline: mitigate, apply, Damaged/Died --------------------
|
||||
spawn Fighter { Stats { hp: 100, max_hp: 100, def: 5 }, Faction { id: 2 } }
|
||||
var t = World.query_next(PS, 0)
|
||||
if t == a { t = World.query_next(PS, a + 1) }
|
||||
print(Combat.damage(t, a, 20)) # 15 — 20 - def 5
|
||||
print(World.get(t, PS, fhp)) # 85
|
||||
|
||||
# mutable hook: enrage halves the next hit ((20 - 5) -> 7)
|
||||
enraged = 1
|
||||
print(Combat.damage(t, a, 20)) # 7
|
||||
enraged = 0
|
||||
|
||||
# veto hook: an over-100 hit is cancelled (0 applied)
|
||||
print(bi(Combat.damage(t, a, 200) == 0)) # 1
|
||||
|
||||
# finish it off with sub-100 hits so the veto never blocks the kill
|
||||
var guard = 0
|
||||
while (World.get(t, PS, fhp) > 0) and (guard < 100) { Combat.damage(t, a, 90); guard = guard + 1 }
|
||||
print(deaths) # 1
|
||||
|
||||
quit()
|
||||
}
|
||||
}
|
||||
76
packages/ludic.gameplay/combat.ludic
Normal file
76
packages/ludic.gameplay/combat.ludic
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# combat.ludic — the shared damage pipeline (#57 foundation).
|
||||
#
|
||||
# One place every family routes hits through, so buffs/armor/crits/lifesteal are
|
||||
# written once as event listeners rather than re-invented per genre. The pipeline
|
||||
# is the honest no-closure realisation of lever 4 (events) + lever 6 (a mutable
|
||||
# hook):
|
||||
#
|
||||
# 1. mitigate the raw amount by the target's effective defence (Stats.total);
|
||||
# 2. publish the pending amount and emit `cancellable DamageAboutToApply` —
|
||||
# a listener may veto the hit (cancel) or *rewrite the number* by calling
|
||||
# Combat.set_amount(n) (the mutable channel, since `emit` only returns the
|
||||
# cancelled flag);
|
||||
# 3. apply the final amount to Stats.hp, emit `Damaged`, and emit `Died` when hp
|
||||
# reaches zero.
|
||||
#
|
||||
# Determinism: integer arithmetic, fixed listener order, no wall-clock — a hit
|
||||
# replays identically. Combat never moves or despawns bodies itself; the game
|
||||
# reacts to `Died` (respawn, ragdoll, loot) so the policy stays with the game.
|
||||
|
||||
event cancellable DamageAboutToApply { target: int = 0, src: int = 0, amount: int = 0 }
|
||||
event Damaged { target: int = 0, src: int = 0, amount: int = 0 }
|
||||
event Died { e: int = 0, src: int = 0 }
|
||||
event Healed { e: int = 0, amount: int = 0 }
|
||||
|
||||
# the mutable channel for lever 6: listeners rewrite the pending damage here.
|
||||
var combat_pending: int = 0
|
||||
|
||||
@Namespace(Combat) function combat_amount() -> int { return combat_pending }
|
||||
@Namespace(Combat) function combat_set_amount(n: int) -> void { combat_pending = n }
|
||||
|
||||
# Combat.mitigate(target, raw) — raw minus effective defence, floored at 1 so any
|
||||
# landed hit chips at least a point (a common, tunable default). A game that wants
|
||||
# a different formula overrides via the DamageAboutToApply hook.
|
||||
@Namespace(Combat) function combat_mitigate(target: int, raw: int) -> int {
|
||||
let d = stats_total(target, 2) # stat 2 = def
|
||||
var v = raw - d
|
||||
if v < 1 { v = 1 }
|
||||
return v
|
||||
}
|
||||
|
||||
# Combat.damage(target, src, amount) — the full pipeline. Returns the damage
|
||||
# actually applied (0 when vetoed or the target has no Stats/hp).
|
||||
@Namespace(Combat) function combat_damage(target: int, src: int, amount: int) -> int {
|
||||
let P = World.prop_id("Stats")
|
||||
if P < 0 { return 0 }
|
||||
if World.has(target, P) == 0 { return 0 }
|
||||
let fhp = World.field_id(P, "hp")
|
||||
if fhp < 0 { return 0 }
|
||||
|
||||
combat_pending = combat_mitigate(target, amount)
|
||||
if emit DamageAboutToApply(target: target, src: src, amount: combat_pending) != 0 { return 0 }
|
||||
var dmg = combat_pending
|
||||
if dmg < 0 { dmg = 0 }
|
||||
|
||||
var hp = World.get(target, P, fhp) - dmg
|
||||
if hp < 0 { hp = 0 }
|
||||
World.set(target, P, fhp, hp)
|
||||
emit Damaged(target: target, src: src, amount: dmg)
|
||||
if hp <= 0 { emit Died(e: target, src: src) }
|
||||
return dmg
|
||||
}
|
||||
|
||||
# Combat.heal(e, amount) — restore hp, clamped to max_hp (effective, so a +max_hp
|
||||
# buff raises the ceiling), and emit `Healed`.
|
||||
@Namespace(Combat) function combat_heal(e: int, amount: int) -> void {
|
||||
let P = World.prop_id("Stats")
|
||||
if P < 0 { return }
|
||||
if World.has(e, P) == 0 { return }
|
||||
let fhp = World.field_id(P, "hp")
|
||||
if fhp < 0 { return }
|
||||
let cap = stats_total(e, 0) # stat 0 = max_hp
|
||||
var hp = World.get(e, P, fhp) + amount
|
||||
if (cap > 0) and (hp > cap) { hp = cap }
|
||||
World.set(e, P, fhp, hp)
|
||||
emit Healed(e: e, amount: amount)
|
||||
}
|
||||
53
packages/ludic.gameplay/cooldown.ludic
Normal file
53
packages/ludic.gameplay/cooldown.ludic
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# cooldown.ludic — a generic deterministic frame timer (#57 foundation).
|
||||
#
|
||||
# Ubiquitous across the controllers: jump buffers, fire rate, ability cooldowns,
|
||||
# AI re-plan intervals, i-frames. A body carries a `Cooldown` and the engine ticks
|
||||
# it down one frame at a time (never below zero); the game reads `Cooldown.ready`.
|
||||
# It is the smallest possible engine system and the template for the rest: resolve
|
||||
# fields by name, no-op on anything absent, mutate in place, no allocation.
|
||||
#
|
||||
# For more than one timer on one entity a controller carries its own countdown
|
||||
# fields (the platformer keeps coyote/buffer counters inside `Platformer`); this
|
||||
# component is the shared, drop-in single timer.
|
||||
|
||||
property Cooldown { t: int = 0, max: int = 0 }
|
||||
|
||||
# esys_cooldown (Update): decrement every live Cooldown toward zero. Registered on
|
||||
# the compile-time engine-system registry via @EngineSystem, so a game that merely
|
||||
# declares `Cooldown` gets it ticked for free, and a game that never mentions
|
||||
# Cooldown compiles byte-identically (the call is component-gated).
|
||||
@EngineSystem(Cooldown, Update) function esys_cooldown() -> void {
|
||||
let P = World.prop_id("Cooldown")
|
||||
if P < 0 { return }
|
||||
let ft = World.field_id(P, "t")
|
||||
if ft < 0 { return }
|
||||
var e = World.query_next(P, 0)
|
||||
while e >= 0 {
|
||||
let t = World.get(e, P, ft)
|
||||
if t > 0 { World.set(e, P, ft, t - 1) }
|
||||
e = World.query_next(P, e + 1)
|
||||
}
|
||||
}
|
||||
|
||||
# Cooldown.start(e, n) — begin an n-frame countdown (also records `max` so a UI can
|
||||
# draw a fill ratio). Cooldown.ready(e) is 1 once it hits zero; Cooldown.left(e) is
|
||||
# the frames remaining.
|
||||
@Namespace(Cooldown) function cooldown_start(e: int, frames: int) -> void {
|
||||
let P = World.prop_id("Cooldown")
|
||||
if P < 0 { return }
|
||||
let ft = World.field_id(P, "t")
|
||||
let fm = World.field_id(P, "max")
|
||||
if ft >= 0 { World.set(e, P, ft, frames) }
|
||||
if fm >= 0 { World.set(e, P, fm, frames) }
|
||||
}
|
||||
@Namespace(Cooldown) function cooldown_left(e: int) -> int {
|
||||
let P = World.prop_id("Cooldown")
|
||||
if P < 0 { return 0 }
|
||||
let ft = World.field_id(P, "t")
|
||||
if ft < 0 { return 0 }
|
||||
return World.get(e, P, ft)
|
||||
}
|
||||
@Namespace(Cooldown) function cooldown_ready(e: int) -> int {
|
||||
if cooldown_left(e) <= 0 { return 1 }
|
||||
return 0
|
||||
}
|
||||
70
packages/ludic.gameplay/faction.ludic
Normal file
70
packages/ludic.gameplay/faction.ludic
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# faction.ludic — a friend/enemy/neutral relationship table (#57 foundation).
|
||||
#
|
||||
# Shared by shooter targeting and NPC AI: "who may hit / hunt / help whom". Each
|
||||
# body carries a `Faction { id }`; a small square matrix records the relationship
|
||||
# between two faction ids. Unset pairs fall back to the classic default — same id
|
||||
# is friendly, different ids are hostile — so a game that never touches the table
|
||||
# still gets sensible enemy/ally behaviour. The matrix is a plain integer buffer
|
||||
# in a module var, so it is deterministic and captured by world_save.
|
||||
|
||||
property Faction { id: int = 0 }
|
||||
|
||||
const FAC_MAX: int = 32
|
||||
const REL_FRIEND: int = 0
|
||||
const REL_NEUTRAL: int = 1
|
||||
const REL_HOSTILE: int = 2
|
||||
|
||||
# relationships stored as (rel + 1); a zero slot means "unset -> use the default".
|
||||
# A global var initialiser must be a literal, so it is allocated lazily.
|
||||
var fac_tbl: words = null
|
||||
|
||||
function fac_ensure() -> void {
|
||||
if fac_tbl == null { fac_tbl = words(FAC_MAX * FAC_MAX) }
|
||||
}
|
||||
|
||||
# Faction.set(a, b, rel) — record a symmetric relationship between two factions.
|
||||
@Namespace(Faction) function faction_set(a: int, b: int, rel: int) -> void {
|
||||
fac_ensure()
|
||||
if (a < 0) or (a >= FAC_MAX) { return }
|
||||
if (b < 0) or (b >= FAC_MAX) { return }
|
||||
fac_tbl[a * FAC_MAX + b] = rel + 1
|
||||
fac_tbl[b * FAC_MAX + a] = rel + 1
|
||||
}
|
||||
|
||||
# Faction.rel(a, b) — the relationship code (0 friend, 1 neutral, 2 hostile).
|
||||
@Namespace(Faction) function faction_rel(a: int, b: int) -> int {
|
||||
fac_ensure()
|
||||
if (a >= 0) and (a < FAC_MAX) and (b >= 0) and (b < FAC_MAX) {
|
||||
let v = fac_tbl[a * FAC_MAX + b]
|
||||
if v != 0 { return v - 1 }
|
||||
}
|
||||
if a == b { return REL_FRIEND }
|
||||
return REL_HOSTILE
|
||||
}
|
||||
|
||||
# Faction.hostile(a, b) / Faction.friendly(a, b) — the common yes/no queries.
|
||||
@Namespace(Faction) function faction_hostile(a: int, b: int) -> int {
|
||||
if faction_rel(a, b) == REL_HOSTILE { return 1 }
|
||||
return 0
|
||||
}
|
||||
@Namespace(Faction) function faction_friendly(a: int, b: int) -> int {
|
||||
if faction_rel(a, b) == REL_FRIEND { return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
# Faction.id_of(e) — the faction id an entity carries (-1 if it has no Faction).
|
||||
@Namespace(Faction) function faction_id_of(e: int) -> int {
|
||||
let P = World.prop_id("Faction")
|
||||
if P < 0 { return 0 - 1 }
|
||||
if World.has(e, P) == 0 { return 0 - 1 }
|
||||
let f = World.field_id(P, "id")
|
||||
if f < 0 { return 0 - 1 }
|
||||
return World.get(e, P, f)
|
||||
}
|
||||
|
||||
# Faction.enemies(a, b) — true when the two entities' factions are hostile; the
|
||||
# one-call test the shooter and AI reach for. Missing factions read as hostile
|
||||
# (an unaligned hazard hits anyone) unless the ids happen to match.
|
||||
@Namespace(Faction) function faction_enemies(ea: int, eb: int) -> int {
|
||||
return faction_hostile(faction_id_of(ea), faction_id_of(eb))
|
||||
}
|
||||
18
packages/ludic.gameplay/package.ludic
Normal file
18
packages/ludic.gameplay/package.ludic
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# ludic.gameplay — the shared foundation for the builtin gameplay controllers.
|
||||
#
|
||||
# This is the "foundation" phase of the controller layer (#57): the cross-cutting
|
||||
# building blocks every genre package (platformer / shooter / RPG / NPC-AI) leans
|
||||
# on — a deterministic timer, a stats + timed-modifier stack, a faction
|
||||
# relationship table, and a combat pipeline with cancellable/mutable hooks.
|
||||
#
|
||||
# It ships as an ordinary source package: AOT means these declarations compile
|
||||
# straight into the consumer's compile-time ECS, so there is no ABI seam, and the
|
||||
# whole thing stays deterministic (integer only) — lockstep, replay and
|
||||
# world_save snapshots all hold.
|
||||
package "ludic.gameplay"
|
||||
version "0.1.0"
|
||||
kind source
|
||||
provides "Cooldown"
|
||||
provides "Stats"
|
||||
provides "Faction"
|
||||
provides "Combat"
|
||||
111
packages/ludic.gameplay/stats.ludic
Normal file
111
packages/ludic.gameplay/stats.ludic
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# stats.ludic — attributes + a timed modifier stack (#57 foundation).
|
||||
#
|
||||
# `Stats` is a POD attribute bundle shared by the RPG, shooter and NPC families.
|
||||
# On top of the raw fields sits a *modifier stack*: each buff/debuff is its own
|
||||
# tiny `Modifier` entity (target + stat + op + amount + ttl), so the stack is
|
||||
# unbounded, captured by world_save, and expired by one engine system. A queried
|
||||
# stat is `base + flat`, then scaled by the summed percent — the standard
|
||||
# flat-then-percent order — computed on demand, never mutating the base.
|
||||
#
|
||||
# Stat codes (the `stat` argument everywhere below):
|
||||
# 0 = max_hp 1 = atk 2 = def 3 = spd 4 = max_mp
|
||||
|
||||
property Stats {
|
||||
hp: int = 0, max_hp: int = 0,
|
||||
mp: int = 0, max_mp: int = 0,
|
||||
atk: int = 0, def: int = 0, spd: int = 0,
|
||||
level: int = 1, xp: int = 0
|
||||
}
|
||||
|
||||
# a single timed modifier, carried on its own entity so the stack is dynamic.
|
||||
# op: 0 = flat add, 1 = percent add. ttl: frames left (0 or less = permanent).
|
||||
property Modifier { target: int = 0, stat: int = 0, op: int = 0, amount: int = 0, ttl: int = 0 }
|
||||
model ModifierFx { Modifier }
|
||||
@OnSpawn(ModifierFx) handler ModifierFxInit { }
|
||||
|
||||
# esys_modifier (Update): count down every timed modifier and despawn the expired
|
||||
# ones. Permanent modifiers (ttl <= 0) are left alone.
|
||||
@EngineSystem(Modifier, Update) function esys_modifier() -> void {
|
||||
let P = World.prop_id("Modifier")
|
||||
if P < 0 { return }
|
||||
let fttl = World.field_id(P, "ttl")
|
||||
if fttl < 0 { return }
|
||||
var e = World.query_next(P, 0)
|
||||
while e >= 0 {
|
||||
let ttl = World.get(e, P, fttl)
|
||||
var nxt = World.query_next(P, e + 1)
|
||||
if ttl > 0 {
|
||||
let left = ttl - 1
|
||||
if left <= 0 { despawn e } else { World.set(e, P, fttl, left) }
|
||||
}
|
||||
e = nxt
|
||||
}
|
||||
}
|
||||
|
||||
# map a stat code to the matching Stats field name.
|
||||
function stat_field(stat: int) -> pointer {
|
||||
if stat == 0 { return "max_hp" }
|
||||
if stat == 1 { return "atk" }
|
||||
if stat == 2 { return "def" }
|
||||
if stat == 3 { return "spd" }
|
||||
if stat == 4 { return "max_mp" }
|
||||
return "atk"
|
||||
}
|
||||
|
||||
# Stats.base(e, stat) — the raw stored field, no modifiers.
|
||||
@Namespace(Stats) function stats_base(e: int, stat: int) -> int {
|
||||
let P = World.prop_id("Stats")
|
||||
if P < 0 { return 0 }
|
||||
let f = World.field_id(P, stat_field(stat))
|
||||
if f < 0 { return 0 }
|
||||
return World.get(e, P, f)
|
||||
}
|
||||
|
||||
# Stats.total(e, stat) — the effective value: (base + flat) * (100 + percent)/100.
|
||||
@Namespace(Stats) function stats_total(e: int, stat: int) -> int {
|
||||
let base = stats_base(e, stat)
|
||||
let P = World.prop_id("Modifier")
|
||||
if P < 0 { return base }
|
||||
let ft = World.field_id(P, "target")
|
||||
let fs = World.field_id(P, "stat")
|
||||
let fo = World.field_id(P, "op")
|
||||
let fa = World.field_id(P, "amount")
|
||||
var flat = 0
|
||||
var pct = 0
|
||||
var m = World.query_next(P, 0)
|
||||
while m >= 0 {
|
||||
if (World.get(m, P, ft) == e) and (World.get(m, P, fs) == stat) {
|
||||
let amt = World.get(m, P, fa)
|
||||
if World.get(m, P, fo) == 1 { pct = pct + amt } else { flat = flat + amt }
|
||||
}
|
||||
m = World.query_next(P, m + 1)
|
||||
}
|
||||
return (base + flat) * (100 + pct) / 100
|
||||
}
|
||||
|
||||
# Stats.modify(target, stat, op, amount, ttl) — push a modifier onto the stack.
|
||||
@Namespace(Stats) function stats_modify(target: int, stat: int, op: int, amount: int, ttl: int) -> int {
|
||||
let m = World.model_id("ModifierFx")
|
||||
if m < 0 { return 0 - 1 }
|
||||
let e = World.spawn(m)
|
||||
let P = World.prop_id("Modifier")
|
||||
World.set(e, P, World.field_id(P, "target"), target)
|
||||
World.set(e, P, World.field_id(P, "stat"), stat)
|
||||
World.set(e, P, World.field_id(P, "op"), op)
|
||||
World.set(e, P, World.field_id(P, "amount"), amount)
|
||||
World.set(e, P, World.field_id(P, "ttl"), ttl)
|
||||
return e
|
||||
}
|
||||
|
||||
# Stats.clear_mods(e) — drop every modifier targeting an entity (e.g. on unequip).
|
||||
@Namespace(Stats) function stats_clear_mods(target: int) -> void {
|
||||
let P = World.prop_id("Modifier")
|
||||
if P < 0 { return }
|
||||
let ft = World.field_id(P, "target")
|
||||
var m = World.query_next(P, 0)
|
||||
while m >= 0 {
|
||||
var nxt = World.query_next(P, m + 1)
|
||||
if World.get(m, P, ft) == target { despawn m }
|
||||
m = nxt
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +55,7 @@ function emit_call_one(d: Node) -> void {
|
|||
# settled when the engine advances animation / accumulates light. Keep the
|
||||
# component list in sync with uses_engine_systems (emit_ecs.ludic).
|
||||
function emit_one_engine_system(comp: pointer, fn: pointer) -> void {
|
||||
if is_system_disabled(fn) { return } # lever 5 (#57): `disable system <fn>` drops the tick
|
||||
if (find_comp(comp) != null) and (find_fn(fn) != null) {
|
||||
emit(" call void @fn_"); emit(fn); emit("()\n")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ function emit_detach(st: Node) -> void {
|
|||
# flag). A bare `<Model>` / `<Handler>` flips a global enabled flag.
|
||||
function emit_toggle(st: Node) -> void {
|
||||
var val = "0"; if st.ival == 1 { val = "1" }
|
||||
if (st.ty != null) and (st.ty == "system") { return } # `disable system <fn>` is compile-time (handled in emit_one_engine_system); no runtime code
|
||||
if (st.ty != null) and (st.ty == "layer") { # enable/disable layer L
|
||||
emit(" store i32 "); emit(val); emit(", ptr @LE_"); emit(st.s); emit("\n")
|
||||
var lev = `layer_{st.s}_hide`; if st.ival == 1 { lev = `layer_{st.s}_show` } # public layer -> event
|
||||
|
|
|
|||
|
|
@ -348,6 +348,7 @@ function stmt_body() -> Node {
|
|||
var en = 0; if (t.text == "enable") { en = 1 }
|
||||
pi = pi + 1; let n = node(S_TOGGLE); n.ival = en
|
||||
if is_id("layer") { pi = pi + 1; n.ty = "layer"; n.s = eat_id(); note_toggled_layer(n.s); return n } # enable/disable layer L
|
||||
if is_id("system") { pi = pi + 1; n.ty = "system"; n.s = eat_id(); if en == 0 { push(g_disabled_sys, n.s) }; return n } # disable system <esys_fn> (lever 5, compile-time)
|
||||
n.s = eat_id() # `enable P on e` / `disable Model` / `disable Handler`
|
||||
if is_id("on") { pi = pi + 1; n.a = expr() } # property on an entity
|
||||
return n
|
||||
|
|
@ -459,6 +460,19 @@ var g_esys_fn: []pointer
|
|||
var g_esys_phase: []pointer
|
||||
var g_namespaces: []pointer
|
||||
|
||||
# lever 5 of the controller extensibility contract (#57): `disable system <fn>`
|
||||
# switches off exactly one engine-owned system (an esys_* registered via
|
||||
# @EngineSystem or the core seed) at compile time, so a game can carry a
|
||||
# well-known component but tick it with its own handler instead. Recorded at
|
||||
# parse time and read by emit_one_engine_system, which then skips that call
|
||||
# (byte-identical for any program that disables nothing).
|
||||
var g_disabled_sys: []pointer
|
||||
function is_system_disabled(fn: pointer) -> bool {
|
||||
var i = 0
|
||||
while i < len(g_disabled_sys) { if (g_disabled_sys[i] == fn) { return true }; i = i + 1 }
|
||||
return false
|
||||
}
|
||||
|
||||
# #62: package namespace registry — a package marks a Foo.* provider with
|
||||
# @Namespace(Foo); emit_ns_call aliases an otherwise-unknown Foo.method to the
|
||||
# bare function foo_method (lowercased namespace + "_" + method).
|
||||
|
|
@ -874,6 +888,7 @@ function parse_program() -> void {
|
|||
# emit order (SpriteAnim, Motion — Update; Light2D — Render), so a core game is
|
||||
# byte-identical; packages append via @EngineSystem.
|
||||
g_esys_comp = new []pointer; g_esys_fn = new []pointer; g_esys_phase = new []pointer
|
||||
g_disabled_sys = new []pointer
|
||||
push(g_esys_comp, "SpriteAnim"); push(g_esys_fn, "esys_spriteanim"); push(g_esys_phase, "Update")
|
||||
push(g_esys_comp, "Motion"); push(g_esys_fn, "esys_motion"); push(g_esys_phase, "Update")
|
||||
push(g_esys_comp, "Body"); push(g_esys_fn, "esys_move"); push(g_esys_phase, "Update")
|
||||
|
|
|
|||
36461
selfhost/ludicc.seed.ll
36461
selfhost/ludicc.seed.ll
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue