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:
parent
e9c2c51bc3
commit
ad548840c7
139 changed files with 58981 additions and 43671 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -22,3 +22,5 @@ provides "Collider"
|
|||
provides "Solids"
|
||||
provides "Sprite"
|
||||
provides "Bounds"
|
||||
provides "SpriteAnim"
|
||||
provides "TileSkin"
|
||||
|
|
|
|||
124
packages/ludic.dungeon/dungeon.ludic
Normal file
124
packages/ludic.dungeon/dungeon.ludic
Normal 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)
|
||||
}
|
||||
6
packages/ludic.dungeon/package.ludic
Normal file
6
packages/ludic.dungeon/package.ludic
Normal 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"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
7
packages/ludic.prefs/package.ludic
Normal file
7
packages/ludic.prefs/package.ludic
Normal 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"
|
||||
121
packages/ludic.prefs/prefs.ludic
Normal file
121
packages/ludic.prefs/prefs.ludic
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 { }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue