feat(engine): #90 atlas-aware Sprite component, #91 become from listeners, 0.3.x ergonomics batch
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 32s
ci / build-and-test (push) Successful in 2m49s
commit-lint / conventional-commits (push) Successful in 5s
docs / build-and-deploy (push) Successful in 30s

Closes the two open issues and lands the pending unreleased batch:

- #90: `Sprite { atlas: 1 }` routes esys_sprite through atlas_draw_ex
  (scale/flip/tint), so cell / cell_span / strip ids of any size draw
  through the engine sprite-render system. examples/library/sprite_atlas
  is the pixel-readback regression.
- #91: `become` from an @On(Event) listener / global handler / plain
  function no longer segfaults the compiler; it emits @L_scene_leave()
  (a dispatch on the live scene id) so the leaving scene's on-exit runs.
  UI_* handles are readable from any code (widget table built on first
  use). examples/library/scene_menus covers it.
- fix: a windowed `ludicc -o` build that reaches the audio runtime only
  through the atlas/Assets preload import now links audio.ll +
  AVFoundation (the audio backend link was gated on a game-level
  Audio.* call, so any windowed game declaring Sprite failed to link).
- the hand-written "Unreleased" CHANGELOG section is converted to
  changesets under changes/ so `x release` generates it.
- plus the batch: engine-driven retained UI + UiClicked event, Overlay
  phase, TileSkin tilemap-render system, Key.* constants, Font/Ui/File
  namespaces, Sprite.strip, prefabs, managers, countdown fields,
  enum-typed machines, layer @Queries, ludic.prefs / ludic.dungeon
  packages, Ai.seek pathing, Solids.solid2, cursor confine (mode 3)
  fix, shooter centre-aim fix, reserved-word function diagnostic.

Verified: x test (124/124), x test-tools, check-impl, check-vocabulary,
check-docs, docs-gen + docs-check, bootstrap-cfree (seed is a fixpoint).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-04 01:36:08 +03:00
parent e9c2c51bc3
commit ad548840c7
139 changed files with 58981 additions and 43671 deletions

View file

@ -22,6 +22,7 @@ property Position { x: int = 0, y: int = 0 }
# on_ground / hit_wall / hit_ceiling and the optional contact normal hit_nx/hit_ny
# are OUTPUTS: read them in a handler to raise your own landing / bonk events
# (the SpriteAnim.event_fired pattern — polled flags, no engine coupling).
enum BodyPolicy { Platformer, TopDown } # Body.policy: gravity applies / no gravity
property Body {
vx: fixed = 0.0, vy: fixed = 0.0,
gravity: fixed = 0.0, max_fall: fixed = 0.0,
@ -48,15 +49,46 @@ property Collider {
# Position anchor, scale is an integer zoom (0/1 = 1:1), flip mirrors horizontally,
# tint (non-zero) draws every opaque pixel in one colour (hit flash), hidden = 1
# skips it. Draw order is spawn order; a game wanting custom draw omits Sprite.
property Sprite { id: int = 0, offx: int = 0, offy: int = 0, scale: int = 1, flip: int = 0, tint: int = 0, hidden: int = 0 }
# atlas = 1 marks `id` as an atlas sprite (Sprite.cell / Sprite.cell_span — any size,
# including multi-cell characters); 0 = the 16x16 sprite table (png_load). (#90)
# move_id: the strip drawn while the entity moves (0 = always `id`); face = 1 turns
# the sprite toward its movement (TopDown intent, else Body velocity); flash and
# blink are countdowns: white tint while flash > 0, hidden every other pair of
# frames while blink > 0 (a hit flash and invulnerability blink, no handler).
property Sprite {
id: int = 0, offx: int = 0, offy: int = 0, scale: int = 1, flip: int = 0, tint: int = 0, hidden: int = 0, atlas: int = 0,
move_id: int = 0, face: int = 0, flash: countdown = 0, blink: countdown = 0
}
# Collider.center(e): the middle of an entity's box, from its Position and Collider
@Namespace(Collider) function collider_center(e: int) -> IVec2 {
let PP = World.prop_id("Position")
let PC = World.prop_id("Collider")
var x = 0; var y = 0; var w = 0; var h = 0
if (PP >= 0) and (World.has(e, PP) != 0) { x = World.get(e, PP, World.field_id(PP, "x")); y = World.get(e, PP, World.field_id(PP, "y")) }
if (PC >= 0) and (World.has(e, PC) != 0) { w = World.get(e, PC, World.field_id(PC, "w")); h = World.get(e, PC, World.field_id(PC, "h")) }
return IVec2.make(x + w / 2, y + h / 2)
}
# Optional single config entity that turns the tile-grid broadphase on: solids are
# read from the Map.* tilemap. tile > 0 sets the tile size in px; wall is the solid
# glyph; oneway is an optional one-way-platform glyph (0 = none).
property Solids { tile: int = 0, wall: int = 0, oneway: int = 0 }
# solid2 is an optional second solid glyph (0 = none) — e.g. a closed door drawn with
# its own TileSkin while it blocks movement.
property Solids { tile: int = 0, wall: int = 0, oneway: int = 0, solid2: int = 0 }
# The engine-advanced animation clock (esys_spriteanim): `frame` is the current cell
# (0..frames-1) and the sprite system adds it to Sprite.id. mode 0 loop, 1 once, 2 pingpong.
enum AnimMode { Loop, Once, PingPong } # SpriteAnim.mode
property SpriteAnim { ticks: int = 0, fps: int = 8, frames: int = 4, mode: int = 0, frame: int = 0 }
# One entity per map glyph: the engine tilemap-render system paints every Map.* cell
# carrying `glyph` with `sprite` (atlas id when atlas = 1), before sprites are drawn.
property TileSkin { glyph: int = 0, sprite: int = 0, atlas: int = 1, size: int = 16, scale: int = 1 }
# Optional single config entity defining a world boundary / play area (#84): a rect
# (x, y, w, h) and a policy the engine applies to every moving Body each frame —
# 0 clamp (walls), 1 wrap (toroidal), 2 bounce (flip velocity), 3 kill (despawn when
# fully outside). Off by default (no Bounds entity = open world).
enum BoundsPolicy { Clamp, Wrap, Bounce, Kill } # Bounds.policy
property Bounds { x: int = 0, y: int = 0, w: int = 0, h: int = 0, policy: int = 0 }

View file

@ -22,3 +22,5 @@ provides "Collider"
provides "Solids"
provides "Sprite"
provides "Bounds"
provides "SpriteAnim"
provides "TileSkin"

View file

@ -0,0 +1,124 @@
# dungeon.ludic — `Dungeon.*`: arena rooms for a room-to-room roguelite, built
# straight into the engine tilemap (Map.*).
#
# Dungeon.arena(style, wall, floor) a walled room with mirrored cover in one of
# the RoomStyles, and the door lanes (two cells
# wide, through the centre) always open
# Dungeon.open_arena(wall, floor) the walled room alone (a boss arena)
# Dungeon.random_style() a style roll (Scatter twice as likely)
# Dungeon.set_exit(side, glyph) the two centre cells of one edge
# Dungeon.entry_point(side, inset, w, h) where a w x h body starts after entering
# Dungeon.opposite(side) the side across the room
# Dungeon.at_edge(tile) is a tile on the border? (an open doorway is
# the only border cell a body can stand in)
#
# Cover never touches the two-tile margin, and every style mirrors across both axes,
# so every quadrant stays reachable. Deterministic: the seeded RNG drives it all.
enum Side { North, South, West, East } # opposite = side ^ 1
enum RoomStyle { Pillars, Columns, CenterSlab, Bars, Scatter }
const DUNGEON_MARGIN: int = 2
var dungeon_wall: int = '#'
var dungeon_floor: int = '.'
function dungeon_columns() -> int { return Map.width() }
function dungeon_rows() -> int { return Map.height() }
function dungeon_door_column() -> int { return Map.width() / 2 - 1 } # the left of the two door cells
function dungeon_door_row() -> int { return Map.height() / 2 } # the top of the two door cells
# a wall, but never in the margin so rooms stay traversable
function dungeon_place(x: int, y: int) -> void {
if (x < DUNGEON_MARGIN) or (x > dungeon_columns() - 1 - DUNGEON_MARGIN) { return }
if (y < DUNGEON_MARGIN) or (y > dungeon_rows() - 1 - DUNGEON_MARGIN) { return }
Map.set(x: x, y: y, glyph: dungeon_wall)
}
function dungeon_mirrored(x: int, y: int) -> void {
dungeon_place(x, y)
dungeon_place(dungeon_columns() - 1 - x, y)
dungeon_place(x, dungeon_rows() - 1 - y)
dungeon_place(dungeon_columns() - 1 - x, dungeon_rows() - 1 - y)
}
function dungeon_pillar(x: int, y: int) -> void { # 2x2, mirrored four ways
dungeon_mirrored(x, y)
dungeon_mirrored(x + 1, y)
dungeon_mirrored(x, y + 1)
dungeon_mirrored(x + 1, y + 1)
}
function dungeon_style(style: int) -> void {
let cols = dungeon_columns()
let rows = dungeon_rows()
match style {
RoomStyle.Pillars => {
let x = Random.range(low: 3, high: 5)
let y = Random.range(low: 3, high: 4)
dungeon_pillar(x, y)
if Random.chance(percent: 60) { dungeon_pillar(x + 3, y + 2) }
}
RoomStyle.Columns => {
let x = Random.range(low: 4, high: 6)
for y in 3 .. rows - 3 { dungeon_mirrored(x, y) }
}
RoomStyle.CenterSlab => { Map.rect(x: 6, y: 5, width: cols - 12, height: rows - 10, glyph: dungeon_wall) }
RoomStyle.Bars => {
let y = Random.range(low: 3, high: 4)
for x in 4 .. cols - 4 { dungeon_mirrored(x, y) }
}
_ => {
for i in 0 .. Random.range(low: 3, high: 5) {
let x = Random.range(low: 3, high: cols / 2 - 1)
dungeon_place(x, Random.range(low: 3, high: rows - 4))
dungeon_place(cols - 1 - x, Random.range(low: 3, high: rows - 4))
}
}
}
}
@Namespace(Dungeon) function dungeon_open_arena(wall: int, floor: int) -> void {
dungeon_wall = wall
dungeon_floor = floor
Map.fill(glyph: floor)
Map.border(glyph: wall)
}
@Namespace(Dungeon) function dungeon_arena(style: int, wall: int, floor: int) -> void {
dungeon_open_arena(wall, floor)
dungeon_style(style)
Map.rect(x: dungeon_door_column(), y: 1, width: 2, height: dungeon_rows() - 2, glyph: floor) # the door lanes
Map.rect(x: 1, y: dungeon_door_row(), width: dungeon_columns() - 2, height: 2, glyph: floor)
}
@Namespace(Dungeon) function dungeon_random_style() -> int { return Random.range(low: 0, high: 5) }
# the first of an exit's two cells, and the step to the second
function dungeon_exit_cell(side: int) -> IVec2 {
match side {
Side.North => { return IVec2.make(dungeon_door_column(), 0) }
Side.South => { return IVec2.make(dungeon_door_column(), dungeon_rows() - 1) }
Side.West => { return IVec2.make(0, dungeon_door_row()) }
_ => { return IVec2.make(dungeon_columns() - 1, dungeon_door_row()) }
}
}
@Namespace(Dungeon) function dungeon_set_exit(side: int, glyph: int) -> void {
let first = dungeon_exit_cell(side)
var second = IVec2.add(first, IVec2.make(0, 1))
if side <= Side.South { second = IVec2.add(first, IVec2.make(1, 0)) }
Map.set(x: first.x, y: first.y, glyph: glyph)
Map.set(x: second.x, y: second.y, glyph: glyph)
}
# where a body of w x h px starts after entering from `side`, `inset` px inside the edge
@Namespace(Dungeon) function dungeon_entry_point(side: int, inset: int, w: int, h: int) -> IVec2 {
let tile = rt_map_tile_px
let across_x = (dungeon_door_column() + 1) * tile - w / 2
let across_y = dungeon_door_row() * tile + (2 * tile - h) / 2
match side {
Side.North => { return IVec2.make(across_x, inset) }
Side.South => { return IVec2.make(across_x, dungeon_rows() * tile - inset - h) }
Side.West => { return IVec2.make(inset, across_y) }
_ => { return IVec2.make(dungeon_columns() * tile - inset - w, across_y) }
}
}
@Namespace(Dungeon) function dungeon_opposite(side: int) -> int { return side ^ 1 }
@Namespace(Dungeon) function dungeon_at_edge(tile: IVec2) -> bool {
return (tile.x == 0) or (tile.y == 0) or (tile.x == dungeon_columns() - 1) or (tile.y == dungeon_rows() - 1)
}

View file

@ -0,0 +1,6 @@
# ludic.dungeon — arena rooms for a room-to-room roguelite, built into the engine
# tilemap: mirrored cover styles, door lanes, exits by side, entry points.
package "ludic.dungeon"
version "0.1.0"
kind source
provides "Dungeon"

View file

@ -21,6 +21,9 @@ event cancellable DamageAboutToApply { target: int = 0, src: int = 0, amount: in
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 }
event Crit { target: int = 0, src: int = 0 } # the attacker's crit_pct fired
var combat_reflecting: bool = false # thorns never reflect thorns
# the mutable channel for lever 6: listeners rewrite the pending damage here.
var combat_pending: int = 0
@ -47,7 +50,11 @@ var combat_pending: int = 0
let fhp = World.field_id(P, "hp")
if fhp < 0 { return 0 }
combat_pending = combat_mitigate(target, amount)
# the attacker's build: damage_pct, then a crit_pct chance to double
var raw = amount * stats_field(src, "damage_pct", 100) / 100
let crit = stats_field(src, "crit_pct", 0)
if (crit > 0) and Random.chance(percent: crit) { raw = raw * 2; emit Crit(target: target, src: src) }
combat_pending = combat_mitigate(target, raw)
if emit DamageAboutToApply(target: target, src: src, amount: combat_pending) != 0 { return 0 }
var dmg = combat_pending
if dmg < 0 { dmg = 0 }
@ -57,6 +64,17 @@ var combat_pending: int = 0
World.set(target, P, fhp, hp)
emit Damaged(target: target, src: src, amount: dmg)
if hp <= 0 { emit Died(e: target, src: src) }
# the attacker's leech, and the defender's thorns (never reflected back again)
if dmg > 0 {
let leech = stats_field(src, "leech_pct", 0)
if (leech > 0) and Random.chance(percent: leech) { combat_heal(src, stats_field(src, "leech_hp", 1)) }
let thorns = stats_field(target, "thorns", 0)
if (thorns > 0) and (src != target) and (not combat_reflecting) and (World.has(src, P) != 0) {
combat_reflecting = true
combat_damage(src, target, thorns)
combat_reflecting = false
}
}
return dmg
}

View file

@ -10,11 +10,58 @@
# Stat codes (the `stat` argument everywhere below):
# 0 = max_hp 1 = atk 2 = def 3 = spd 4 = max_mp
enum StatKind { MaxHp, Attack, Defense, Speed, MaxMp, DamagePct, CritPct, LeechPct, Thorns, FireRatePct } # Stats.* stat codes
enum ModifyOp { Flat, Percent } # Stats.modify op
# The build stats Combat.damage applies by itself: the attacker's damage_pct and
# crit_pct (a crit doubles), its leech_pct chance to heal leech_hp on a hit, the
# defender's thorns dealt back, and fire_rate_pct the weapon system reads.
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
level: int = 1, xp: int = 0,
damage_pct: int = 100, crit_pct: int = 0, leech_pct: int = 0, leech_hp: int = 1, thorns: int = 0, fire_rate_pct: int = 100
}
# a Stats field of an entity by name, or `fallback` when absent
function stats_field(e: int, name: pointer, fallback: int) -> int {
let P = World.prop_id("Stats")
if (P < 0) or (World.has(e, P) == 0) { return fallback }
let f = World.field_id(P, name)
if f < 0 { return fallback }
return World.get(e, P, f)
}
# the Stats field a stat code names
function stats_field_name(stat: int) -> pointer {
match stat {
StatKind.MaxHp => { return "max_hp" }
StatKind.Attack => { return "atk" }
StatKind.Defense => { return "def" }
StatKind.Speed => { return "spd" }
StatKind.MaxMp => { return "max_mp" }
StatKind.DamagePct => { return "damage_pct" }
StatKind.CritPct => { return "crit_pct" }
StatKind.LeechPct => { return "leech_pct" }
StatKind.Thorns => { return "thorns" }
_ => { return "fire_rate_pct" }
}
}
# Stats.add(e, stat, amount): change a base stat in place (a permanent upgrade)
@Namespace(Stats) function stats_add(e: int, stat: int, amount: int) -> void {
let P = World.prop_id("Stats")
if (P < 0) or (World.has(e, P) == 0) { return }
let f = World.field_id(P, stats_field_name(stat))
if f >= 0 { World.set(e, P, f, World.get(e, P, f) + amount) }
}
# Stats.scale_hp(e, percent): hp and max_hp scaled (a floor's difficulty)
@Namespace(Stats) function stats_scale_hp(e: int, percent: int) -> void {
let P = World.prop_id("Stats")
if (P < 0) or (World.has(e, P) == 0) { return }
let fhp = World.field_id(P, "hp")
let fmax = World.field_id(P, "max_hp")
let hp = World.get(e, P, fhp) * percent / 100
World.set(e, P, fhp, hp)
World.set(e, P, fmax, hp)
}
# a single timed modifier, carried on its own entity so the stack is dynamic.

View file

@ -23,11 +23,14 @@ property Vision { range: int = 140, fov: int = 360, scan: int = 6, scan_t: int =
property Memory { has_target: int = 0, target: int = 0, last_x: int = 0, last_y: int = 0, alertness: int = 0, ttl: int = 0 }
# model: 0 FSM, 1 utility, 2 behaviour-tree. state (FSM): 0 patrol,1 chase,2 attack,3 flee.
enum BrainModel { StateMachine, Utility, BehaviourTree } # Brain.model
enum AiState { Patrol, Chase, Attack, Flee } # Brain.state / DecisionMade.action
property Brain {
model: int = 0, state: int = 0,
attack_range: int = 40, flee_pct: int = 0,
intent_x: int = 0, intent_y: int = 0, want_fire: int = 0,
home_x: int = 0, home_y: int = 0, seed: int = 1
home_x: int = 0, home_y: int = 0, seed: int = 1,
hunt_blind: int = 0 # 1 = with no target in sight, seek the nearest hostile anyway (no idle patrol)
}
property Follower { leader: int = 0 - 1, distance: int = 40, mode: int = 0 } # mode 0 follow,1 guard,2 aggressive
property Steering { separate: int = 0, cohere: int = 0, align: int = 0, radius: int = 48 }
@ -147,9 +150,46 @@ function brain_set_state(e: int, st: int) -> void {
}
# steer the Brain intent toward / away from a point (8-way signed intent).
# the Solids config (tile size / wall glyph) when the game declared one, else 0
function ai_solid_tile() -> int {
let ps = World.prop_id("Solids")
if ps < 0 { return 0 }
let se = World.query_next(ps, 0)
if se < 0 { return 0 }
let ft = World.field_id(ps, "tile")
if ft < 0 { return 0 }
return World.get(se, ps, ft)
}
function ai_solid_wall() -> int {
let ps = World.prop_id("Solids")
let se = World.query_next(ps, 0)
return World.get(se, ps, World.field_id(ps, "wall"))
}
# move toward (tx,ty). With a tilemap, a blocked straight line is routed around
# obstacles with Grid.a_star on the tile grid: the intent points at the next
# waypoint, so bodies flow around pillars instead of pushing into them.
function brain_seek(e: int, tx: int, ty: int) -> void {
let px = ai_get("Position", e, "x")
let py = ai_get("Position", e, "y")
let ts = ai_solid_tile()
if ts > 0 {
let wall = ai_solid_wall()
let cx = px / ts; let cy = py / ts
let gx = tx / ts; let gy = ty / ts
if (cx != gx) or (cy != gy) {
if not Grid.line_of_sight(x0: cx, y0: cy, x1: gx, y1: gy, wall: wall) {
let path = Grid.a_star(x0: cx, y0: cy, x1: gx, y1: gy, wall: wall)
if len(path) > 1 {
let nx = path[1].x * ts + ts / 2
let ny = path[1].y * ts + ts / 2
ai_set("Brain", e, "intent_x", sign_i(nx - (px + ts / 2)))
ai_set("Brain", e, "intent_y", sign_i(ny - (py + ts / 2)))
return
}
}
}
}
ai_set("Brain", e, "intent_x", sign_i(tx - px))
ai_set("Brain", e, "intent_y", sign_i(ty - py))
}
@ -226,9 +266,36 @@ function ai_decide_fsm(e: int) -> void {
}
}
} else {
if emit DecisionMade(e: e, action: 0) == 0 { brain_set_state(e, 0); brain_wander(e) }
let prey = brain_nearest_hostile(e)
if (ai_get("Brain", e, "hunt_blind") == 1) and (prey >= 0) {
if emit DecisionMade(e: e, action: 1) == 0 { brain_set_state(e, 1); brain_seek(e, ai_get("Position", prey, "x"), ai_get("Position", prey, "y")) }
} else {
if emit DecisionMade(e: e, action: 0) == 0 { brain_set_state(e, 0); brain_wander(e) }
}
}
}
# the nearest entity hostile to e (by Faction) that has a Position, or -1
function brain_nearest_hostile(e: int) -> int {
let PF = World.prop_id("Faction")
let PP = World.prop_id("Position")
if (PF < 0) or (PP < 0) { return 0 - 1 }
let fac = Faction.id_of(e)
let px = ai_get("Position", e, "x")
let py = ai_get("Position", e, "y")
var best = 0 - 1
var bestd = 0
var o = World.query_next(PF, 0)
while o >= 0 {
if (o != e) and (World.has(o, PP) != 0) and (Faction.hostile(fac, Faction.id_of(o)) == 1) {
let dx = ai_get("Position", o, "x") - px
let dy = ai_get("Position", o, "y") - py
let d = dx * dx + dy * dy
if (best < 0) or (d < bestd) { best = o; bestd = d }
}
o = World.query_next(PF, o + 1)
}
return best
}
# --- (b) utility AI: score each action, act on the highest ---
function ai_decide_utility(e: int) -> void {
@ -386,6 +453,7 @@ function ai_decide_bt(e: int) -> void {
# Ai.* convenience
# ---------------------------------------------------------------------------
@Namespace(Ai) function ai_set_model(e: int, model: int) -> void { ai_set("Brain", e, "model", model) }
@Namespace(Ai) function ai_seek(e: int, tx: int, ty: int) -> void { brain_seek(e, tx, ty) } # path-aware move-toward
@Namespace(Ai) function ai_state(e: int) -> int { return ai_get("Brain", e, "state") }
@Namespace(Ai) function ai_target(e: int) -> int {
if ai_get("Memory", e, "has_target") == 1 { return ai_get("Memory", e, "target") }

View file

@ -0,0 +1,7 @@
# ludic.prefs — a tiny, human-readable key = value store for player preferences and
# records (high scores, options). Text file, one `key=value` per line, integers only;
# a whole-world Save.write is the wrong tool for "remember my best run".
package "ludic.prefs"
version "0.1.0"
kind source
provides "Prefs"

View file

@ -0,0 +1,121 @@
# prefs.ludic — `Prefs.*`: named integer preferences persisted as plain text.
#
# Prefs.load("save/prefs.txt") read the file if it exists (missing = empty)
# Prefs.get("best_floor", 0) an int by name, with a default
# Prefs.set("best_floor", 3) set (and remember) a value
# Prefs.save() write every key back as `key=value` lines
#
# Deterministic and dependency-free: one file, up to PREFS_MAX entries.
const PREFS_MAX: int = 64
var prefs_path: pointer = null
var prefs_names: pointers = null
var prefs_vals: words = null
var prefs_n: int = 0
namespace Prefs {
internal function init() -> void {
if prefs_names != null { return }
prefs_names = bytes(PREFS_MAX * 8)
prefs_vals = words(PREFS_MAX)
prefs_n = 0
}
internal function find(key: pointer) -> int {
var i = 0
while i < prefs_n { if prefs_names[i] == key { return i }; i = i + 1 }
return 0 - 1
}
export function get(key: pointer, fallback: int) -> int {
init()
let i = find(key)
if i < 0 { return fallback }
return prefs_vals[i]
}
# keep the larger of the stored value and `value`; true when `value` is a new record
export function max(key: pointer, value: int) -> bool {
if value <= get(key, 0) { return false }
set(key, value)
return true
}
export function set(key: pointer, value: int) -> void {
init()
var i = find(key)
if i < 0 {
if prefs_n >= PREFS_MAX { return }
i = prefs_n
prefs_names[i] = key
prefs_n = prefs_n + 1
}
prefs_vals[i] = value
}
# the text of a whole file, or null when it does not exist
internal function read_all(path: pointer) -> pointer {
let f = file_open(path, "rb")
if f == null { return null }
file_seek(f, 0, 2)
let size = file_tell(f)
file_seek(f, 0, 0)
let buf = bytes(size + 1)
file_read(f, buf, size)
file_close(f)
buf[size] = 0
return buf
}
# digits after `=` on one line -> an int (a leading '-' negates)
internal function parse_line(buf: pointer, from: int, to: int) -> void {
var eq = from
while (eq < to) and (buf[eq] != 61) { eq = eq + 1 } # '='
if eq >= to { return }
let key = bytes(eq - from + 1)
var k = 0
while k < eq - from { key[k] = buf[from + k]; k = k + 1 }
key[eq - from] = 0
var v = 0
var neg = false
var p = eq + 1
if (p < to) and (buf[p] == 45) { neg = true; p = p + 1 } # '-'
while (p < to) and (buf[p] >= 48) and (buf[p] <= 57) { v = v * 10 + (buf[p] - 48); p = p + 1 }
if neg { v = 0 - v }
set(key, v)
}
export function load(path: pointer) -> void {
init()
prefs_path = path
let buf = read_all(path)
if buf == null { return }
var start = 0
var i = 0
while buf[i] != 0 {
if buf[i] == 10 { parse_line(buf, start, i); start = i + 1 } # newline
i = i + 1
}
if i > start { parse_line(buf, start, i) }
}
internal function write_text(f: pointer, s: pointer) -> void {
var n = 0
while s[n] != 0 { n = n + 1 }
file_write(f, s, n)
}
export function save() -> void {
init()
if prefs_path == null { return }
let f = file_open(prefs_path, "wb")
if f == null { return }
var i = 0
while i < prefs_n {
write_text(f, `{prefs_names[i]}={string(prefs_vals[i])}\n`)
i = i + 1
}
file_close(f)
}
}

View file

@ -11,6 +11,7 @@
import "ludic.gameplay/combat.ludic"
enum StatusKind { Poison, Regen } # Status.apply kind
property Status { target: int = 0, kind: int = 0, amount: int = 0, interval: int = 30, ttl: int = 0, t: int = 0 }
model StatusFx { Status }
@OnSpawn(StatusFx) handler StatusFxInit { }

View file

@ -21,6 +21,8 @@ const WPN_MAX: int = 64
# ---------------------------------------------------------------------------
# Components
# ---------------------------------------------------------------------------
enum AimMode { Mouse, RightStick, MoveDirection, NearestEnemy, Manual, Auto } # TopDown.aim_mode; Auto = mouse, or the right stick while a pad is connected
enum WeaponPattern { Single, Cone, Ring, Spiral } # Weapon.def pattern
property TopDown {
move_speed: int = 4, # px/frame
accel: int = 80, # responsiveness %, gap closed / frame
@ -32,7 +34,9 @@ property TopDown {
want_x: int = 0, # -1/0/1 move intent (input OR ai writes)
want_y: int = 0,
want_fire: int = 0, # 1 = fire held (input OR ai writes)
policy: int = 0
policy: int = 0,
reticle: int = 0, # a colour: the engine draws an aim line of reticle_length px (0 = none)
reticle_length: int = 18 # and a cross at the mouse while aiming with it
}
# a Weapon instance points at a registry entry (def_id). cooldown counts down to
@ -40,6 +44,7 @@ property TopDown {
property Weapon { def_id: int = 0, ammo: int = 0 - 1, cooldown: int = 0, phase: int = 0 }
property Projectile {
color: int = 0xffffff, # drawn by the engine as a 3x3 square (Weapon.set_color)
vx: fixed = 0.0, vy: fixed = 0.0, rx: fixed = 0.0, ry: fixed = 0.0,
dir: int = 0, # heading in degrees (drives homing without re-deriving from velocity)
speed: int = 8, damage: int = 1, ttl: int = 120,
@ -71,6 +76,7 @@ var wpn_pattern: words = null
var wpn_ttl: words = null
var wpn_pierce: words = null
var wpn_homing: words = null
var wpn_color: words = null
function wpn_ensure() -> void {
if wpn_reg == null {
@ -78,6 +84,7 @@ function wpn_ensure() -> void {
wpn_rate = words(WPN_MAX); wpn_dmg = words(WPN_MAX); wpn_speed = words(WPN_MAX)
wpn_spread = words(WPN_MAX); wpn_pellets = words(WPN_MAX); wpn_pattern = words(WPN_MAX)
wpn_ttl = words(WPN_MAX); wpn_pierce = words(WPN_MAX); wpn_homing = words(WPN_MAX)
wpn_color = words(WPN_MAX); wpn_rate_base = words(WPN_MAX)
}
}
@ -90,6 +97,8 @@ function wpn_ensure() -> void {
wpn_rate[id] = fire_rate; wpn_dmg[id] = damage; wpn_speed[id] = speed
wpn_spread[id] = spread; wpn_pellets[id] = pellets; wpn_pattern[id] = pattern
wpn_ttl[id] = 120
wpn_color[id] = 0xffffff
wpn_rate_base[id] = fire_rate
wpn_n = wpn_n + 1
Dict.set(wpn_reg, name, id)
return id
@ -98,6 +107,23 @@ function wpn_ensure() -> void {
wpn_ensure()
return Dict.get_or(wpn_reg, name, 0 - 1)
}
# Weapon.set_rate(id, frames) — frames between shots (a fire-rate upgrade changes it in place).
@Namespace(Weapon) function weapon_set_rate(id: int, frames: int) -> void {
wpn_ensure(); if (id >= 0) and (id < WPN_MAX) { wpn_rate[id] = frames }
}
@Namespace(Weapon) function weapon_rate(id: int) -> int {
wpn_ensure(); if (id >= 0) and (id < WPN_MAX) { return wpn_rate[id] }; return 0
}
# Weapon.reset(id) — back to the definition: no pierce, no homing, the defined fire rate.
var wpn_rate_base: words = null
@Namespace(Weapon) function weapon_reset(id: int) -> void {
wpn_ensure(); if (id < 0) or (id >= WPN_MAX) { return }
wpn_pierce[id] = 0; wpn_homing[id] = 0; wpn_rate[id] = wpn_rate_base[id]
}
# Weapon.set_color(id, color) — the colour the engine draws this weapon's shots in.
@Namespace(Weapon) function weapon_set_color(id: int, color: int) -> void {
wpn_ensure(); if (id >= 0) and (id < WPN_MAX) { wpn_color[id] = color }
}
# Weapon.set_ttl(id, frames) — projectile lifetime for a weapon (range control).
@Namespace(Weapon) function weapon_set_ttl(id: int, frames: int) -> void {
wpn_ensure(); if (id >= 0) and (id < WPN_MAX) { wpn_ttl[id] = frames }
@ -163,8 +189,8 @@ function sh_nearest_enemy(self_e: int, fac: int, px: int, py: int) -> int {
while e >= 0 {
if (e != self_e) and (World.has(e, PP) != 0) {
if Faction.hostile(fac, Faction.id_of(e)) == 1 {
let dx = pos_get(e, "x") - px
let dy = pos_get(e, "y") - py
let dx = sh_cx(e) - px
let dy = sh_cy(e) - py
let d = dx * dx + dy * dy
if (best < 0) or (d < bestd) { best = e; bestd = d }
}
@ -177,6 +203,169 @@ function sh_nearest_enemy(self_e: int, fac: int, px: int, py: int) -> int {
# ===========================================================================
# esys_topdown_move (FixedUpdate) — 2-axis acceleration toward the move intent.
# ===========================================================================
# ===========================================================================
# Dash — the dodge roll: a burst of `speed` for `frames` frames in a fixed
# direction, invulnerable while it lasts, then `cooldown_frames` before the next.
# Dash.start(e, dx, dy) -> bool begin one (a zero direction dashes along the aim);
# false while cooling down
# Dash.active(e) -> bool mid-dash?
# esys_dash (FixedUpdate, before the mover) overrides the movement intent and the
# fire intent for the dash, restoring the walking speed when it ends.
# ===========================================================================
property Dash {
frames: int = 10, speed: int = 11, cooldown_frames: int = 35,
frames_left: countdown = 0, cooldown: countdown = 0, dx: int = 0, dy: int = 0, saved_speed: int = 0
}
# a component field by name (the reflected accessor the Dash / Melee helpers use)
function sh_get(prop: pointer, e: int, name: pointer) -> int {
let P = World.prop_id(prop); if P < 0 { return 0 }
let f = World.field_id(P, name); if f < 0 { return 0 }
return World.get(e, P, f)
}
function sh_set(prop: pointer, e: int, name: pointer, v: int) -> void {
let P = World.prop_id(prop); if P < 0 { return }
let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) }
}
function dash_get(e: int, name: pointer) -> int { return sh_get("Dash", e, name) }
function dash_set(e: int, name: pointer, v: int) -> void { sh_set("Dash", e, name, v) }
@Namespace(Dash) function dash_start(e: int, dx: int, dy: int) -> bool {
if dash_get(e, "cooldown") > 0 { return false }
var d = IVec2.make(dx, dy)
if (dx == 0) and (dy == 0) { d = IVec2.step(td_get(e, "aim_angle")) }
if (d.x == 0) and (d.y == 0) { d = IVec2.make(1, 0) }
dash_set(e, "frames_left", dash_get(e, "frames"))
dash_set(e, "cooldown", dash_get(e, "cooldown_frames"))
dash_set(e, "dx", d.x)
dash_set(e, "dy", d.y)
dash_set(e, "saved_speed", td_get(e, "move_speed"))
td_set(e, "move_speed", dash_get(e, "speed"))
return true
}
@Namespace(Dash) function dash_active(e: int) -> bool { return dash_get(e, "frames_left") > 0 }
@EngineSystem(Dash, FixedUpdate) function esys_dash() -> void {
let P = World.prop_id("Dash")
if P < 0 { return }
var e = World.query_next(P, 0)
while e >= 0 {
if dash_get(e, "frames_left") > 0 {
td_set(e, "want_x", dash_get(e, "dx"))
td_set(e, "want_y", dash_get(e, "dy"))
td_set(e, "want_fire", 0)
} else { if dash_get(e, "saved_speed") > 0 {
td_set(e, "move_speed", dash_get(e, "saved_speed"))
dash_set(e, "saved_speed", 0)
} }
e = World.query_next(P, e + 1)
}
}
@On(DamageAboutToApply) handler DashGuard { if dash_active(target) { cancel } } # i-frames while dashing
# ===========================================================================
# Melee — a swing that hits every hostile inside an arc around the aim.
# Melee.swing(e) -> int hits dealt (-1 while cooling down); each hit takes
# `damage`, is pushed `knockback` px away (never into a
# solid tile), and fires MeleeHit
# Melee.ready(e) / Melee.active(e)
# ===========================================================================
property Melee {
range: int = 24, half_arc: int = 50, damage: int = 10, knockback: int = 8,
frames: int = 8, cooldown_frames: int = 20, frames_left: countdown = 0, cooldown: countdown = 0,
arc: int = 0 # a colour: the engine draws the swing's arc while it lasts (0 = none)
}
# esys_melee_draw (Render): the swing as a circle at the reach, growing with its frames
@EngineSystem(Melee, Render) function esys_melee_draw() -> void {
let P = World.prop_id("Melee")
if P < 0 { return }
var e = World.query_next(P, 0)
while e >= 0 {
let color = melee_get(e, "arc")
let left = melee_get(e, "frames_left")
if (color != 0) and (left > 0) {
let at = IVec2.along(IVec2.make(sh_cx(e), sh_cy(e)), td_get(e, "aim_angle"), melee_get(e, "range"))
Screen.circle(x: at.x, y: at.y, radius: 6 + left, color: color)
}
e = World.query_next(P, e + 1)
}
}
event MeleeHit { attacker: int = 0, target: int = 0 }
function melee_get(e: int, name: pointer) -> int { return sh_get("Melee", e, name) }
@Namespace(Melee) function melee_ready(e: int) -> bool { return melee_get(e, "cooldown") == 0 }
@Namespace(Melee) function melee_active(e: int) -> bool { return melee_get(e, "frames_left") > 0 }
@Namespace(Melee) function melee_swing(e: int) -> int {
if melee_get(e, "cooldown") > 0 { return 0 - 1 }
sh_set("Melee", e, "frames_left", melee_get(e, "frames"))
sh_set("Melee", e, "cooldown", melee_get(e, "cooldown_frames"))
let PF = World.prop_id("Faction")
let PP = World.prop_id("Position")
if (PF < 0) or (PP < 0) { return 0 }
let me = IVec2.make(sh_cx(e), sh_cy(e))
let aim = td_get(e, "aim_angle")
let fac = Faction.id_of(e)
var hits = 0
var o = World.query_next(PF, 0)
while o >= 0 {
if (o != e) and (World.has(o, PP) != 0) and (Faction.hostile(fac, Faction.id_of(o)) == 1) {
let other = IVec2.make(sh_cx(o), sh_cy(o))
let reach = melee_get(e, "range") + sh_half_w(o)
if IVec2.within(me, other, reach) {
let heading = IVec2.heading(me, other)
if abs(Angle.diff_degrees(a: aim, b: heading)) <= melee_get(e, "half_arc") {
Combat.damage(o, e, melee_get(e, "damage"))
let push = IVec2.scale(IVec2.step(heading), melee_get(e, "knockback"))
let nx = pos_get(o, "x") + push.x
let ny = pos_get(o, "y") + push.y
if not Map.is_solid_at(nx, ny) { pos_set(o, "x", nx); pos_set(o, "y", ny) }
emit MeleeHit(attacker: e, target: o)
hits = hits + 1
}
}
}
o = World.query_next(PF, o + 1)
}
return hits
}
function sh_half_w(e: int) -> int {
let PC = World.prop_id("Collider")
if (PC < 0) or (World.has(e, PC) == 0) { return 0 }
return World.get(e, PC, World.field_id(PC, "w")) / 2
}
# esys_topdown_draw (Render): the aim line, and a cross at the mouse while it is the aim
@EngineSystem(TopDown, Render) function esys_topdown_draw() -> void {
let P = World.prop_id("TopDown")
if P < 0 { return }
var e = World.query_next(P, 0)
while e >= 0 {
let color = td_get(e, "reticle")
if color != 0 {
let from = IVec2.make(sh_cx(e), sh_cy(e))
let tip = IVec2.along(from, td_get(e, "aim_angle"), td_get(e, "reticle_length"))
Screen.line(x1: from.x, y1: from.y, x2: tip.x, y2: tip.y, color: color)
var mode = td_get(e, "aim_mode")
if (mode == AimMode.Auto) and (not Input.pad_connected(0)) { mode = AimMode.Mouse }
if mode == AimMode.Mouse {
let mx = Input.mouse_x(); let my = Input.mouse_y()
Screen.line(x1: mx - 3, y1: my, x2: mx + 3, y2: my, color: 0xffffff)
Screen.line(x1: mx, y1: my - 3, x2: mx, y2: my + 3, color: 0xffffff)
}
}
e = World.query_next(P, e + 1)
}
}
# esys_projectile_draw (Render, after the sprites): every shot as a 3x3 square in
# its weapon's colour, through the camera, so a game draws no bullets by hand.
@EngineSystem(Projectile, Render) function esys_projectile_draw() -> void {
let P = World.prop_id("Projectile")
if P < 0 { return }
let fc = World.field_id(P, "color")
var e = World.query_next(P, 0)
while e >= 0 {
if pr_get(e, "ttl") > 0 { Screen.fill_rectangle(x: pos_get(e, "x") - 1, y: pos_get(e, "y") - 1, width: 3, height: 3, color: World.get(e, P, fc)) }
e = World.query_next(P, e + 1)
}
}
@EngineSystem(TopDown, FixedUpdate) function esys_topdown_move() -> void {
let P = World.prop_id("TopDown")
if P < 0 { return }
@ -210,9 +399,10 @@ function sh_nearest_enemy(self_e: int, fac: int, px: int, py: int) -> int {
if P < 0 { return }
var e = World.query_next(P, 0)
while e >= 0 {
let mode = td_get(e, "aim_mode")
let px = pos_get(e, "x")
let py = pos_get(e, "y")
var mode = td_get(e, "aim_mode")
if mode == AimMode.Auto { mode = AimMode.Mouse; if Input.pad_connected(0) { mode = AimMode.RightStick } }
let px = sh_cx(e)
let py = sh_cy(e)
var target = td_get(e, "aim_angle") # default: keep current
var have = 0
if mode == 0 { # mouse / point
@ -234,7 +424,7 @@ function sh_nearest_enemy(self_e: int, fac: int, px: int, py: int) -> int {
if mode == 3 { # nearest enemy (auto-aim)
let fac = Faction.id_of(e)
let t = sh_nearest_enemy(e, fac, px, py)
if t >= 0 { target = sh_angle_to(px, py, pos_get(t, "x"), pos_get(t, "y")); have = 1 }
if t >= 0 { target = sh_angle_to(px, py, sh_cx(t), sh_cy(t)); have = 1 }
}
if have == 1 {
let tr = td_get(e, "turn_rate")
@ -254,6 +444,23 @@ function sh_nearest_enemy(self_e: int, fac: int, px: int, py: int) -> int {
# integer-degree angle helpers (built on Math trig, kept in degrees for the API)
# both take small integer components (pixel deltas or -1/0/1 intent), so fixed()
# stays well inside i32; results are wrapped into 0..359.
# the centre of an entity's body: Position + Collider offset + half size when it has
# a Collider (Position is a box's top-left anchor), else Position itself.
function sh_cx(e: int) -> int {
let PC = World.prop_id("Collider")
if (PC >= 0) and (World.has(e, PC) != 0) {
return pos_get(e, "x") + World.get(e, PC, World.field_id(PC, "offx")) + World.get(e, PC, World.field_id(PC, "w")) / 2
}
return pos_get(e, "x")
}
function sh_cy(e: int) -> int {
let PC = World.prop_id("Collider")
if (PC >= 0) and (World.has(e, PC) != 0) {
return pos_get(e, "y") + World.get(e, PC, World.field_id(PC, "offy")) + World.get(e, PC, World.field_id(PC, "h")) / 2
}
return pos_get(e, "y")
}
function sh_angle_to(ax: int, ay: int, bx: int, by: int) -> int {
let r = Math.atan2(fixed(by - ay), fixed(bx - ax))
return sh_wrap_deg(floor(Math.rad_to_deg(r)))
@ -300,9 +507,11 @@ function sh_spawn_bullet(owner: int, fac: int, px: int, py: int, deg: int, speed
World.set(b, PC, World.field_id(PC, "h"), 4)
}
# announce it: a listener may veto (cancel -> despawn) or tweak the Projectile.
pr_set(b, "color", wpn_fire_color)
if emit ProjectileSpawned(proj: b, owner: owner, damage: dmg) != 0 { despawn b; return 0 - 1 }
return b
}
var wpn_fire_color: int = 0xffffff # the colour of the weapon being fired (sh_fire)
# ===========================================================================
# esys_weapon (Update) — fire-rate gating + pattern emission. Reads the owner's
@ -322,7 +531,7 @@ function sh_spawn_bullet(owner: int, fac: int, px: int, py: int, deg: int, speed
let id = World.get(e, P, World.field_id(P, "def_id"))
if (id >= 0) and (id < wpn_n) {
sh_fire(e, id)
World.set(e, P, World.field_id(P, "cooldown"), wpn_rate[id])
World.set(e, P, World.field_id(P, "cooldown"), wpn_rate[id] * stats_field(e, "fire_rate_pct", 100) / 100)
}
}
e = World.query_next(P, e + 1)
@ -330,8 +539,8 @@ function sh_spawn_bullet(owner: int, fac: int, px: int, py: int, deg: int, speed
}
function sh_fire(owner: int, id: int) -> void {
let px = pos_get(owner, "x")
let py = pos_get(owner, "y")
let px = sh_cx(owner)
let py = sh_cy(owner)
let base = td_get(owner, "aim_angle")
let fac = Faction.id_of(owner)
let speed = wpn_speed[id]
@ -342,6 +551,7 @@ function sh_fire(owner: int, id: int) -> void {
let ttl = wpn_ttl[id]
let pierce = wpn_pierce[id]
let homing = wpn_homing[id]
wpn_fire_color = wpn_color[id]
var pellets = pellets0; if pellets < 1 { pellets = 1 }
emit WeaponFired(owner: owner, def_id: id)
if pattern == 2 { # ring: pellets around 360
@ -387,6 +597,8 @@ function sh_fire(owner: int, id: int) -> void {
let ttl = pr_get(e, "ttl") - 1
if ttl <= 0 { emit ProjectileExpired(proj: e); despawn e; e = nxt; continue }
pr_set(e, "ttl", ttl)
# a solid tile (the Solids config) stops a shot; with no Solids nothing is solid
if Map.is_solid_at(pos_get(e, "x"), pos_get(e, "y")) { emit ProjectileExpired(proj: e); despawn e; e = nxt; continue }
# homing steer toward the nearest enemy of the projectile's faction
let hom = pr_get(e, "homing")
if hom > 0 {
@ -394,7 +606,7 @@ function sh_fire(owner: int, id: int) -> void {
let px0 = pos_get(e, "x"); let py0 = pos_get(e, "y")
let t = sh_nearest_enemy(e, fac, px0, py0)
if t >= 0 {
let want = sh_angle_to(px0, py0, pos_get(t, "x"), pos_get(t, "y"))
let want = sh_angle_to(px0, py0, sh_cx(t), sh_cy(t))
let cur = pr_get(e, "dir")
let d = sh_angle_diff(cur, want)
var na = cur