ludic/packages/ludic.rpg/mover.ludic
Orkuncakilkaya 46c275c4c1
Some checks failed
commit-lint / conventional-commits (push) Waiting to run
docs / build-and-deploy (push) Waiting to run
bootstrap / cfree-fixpoint (push) Successful in 21s
ci / build-and-test (push) Has been cancelled
feat(controllers): #59 RPG systems — ludic.rpg 7-module suite (base + extensible)
Seven independently-usable modules on the six-lever contract with name-keyed
data registries (zero-code content): movement (grid/free/tween, 4/8-axis, veto +
interact), inventory + equipment (stat bonuses via the gameplay modifier stack),
crafting, event-driven quests + flags, an Ink/Yarn dialog graph, Sokoban
pushables + a switch/plate/gate signal graph, and over-time status effects
through the Combat pipeline. Reuses ludic.gameplay Stats/Combat. Deterministic.
21-check example, in the suite (103 passed, 0 failed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 17:25:56 +03:00

177 lines
7.2 KiB
Text

# mover.ludic — RPG Module A: movement (grid + free, 4/8-axis). One component with
# a `mode` selector, so a game picks its movement feel as data (#59).
#
# mode 0 grid discrete cell steps (RPG Maker / Pokemon), cooldown-gated
# mode 1 free continuous Body velocity (top-down Zelda), reuses esys_move
# mode 2 grid-tween grid-discrete logic, positions eased between cells
#
# Events at every decision: cancellable MoveRequested (locked doors, ice, cutscene
# lock), TileEntered (encounters, traps), Interacted (the action-button raycast).
const RPG_Q: int = 65536
property Mover {
mode: int = 0, # 0 grid, 1 free, 2 grid-tween
axes: int = 8, # 4 cardinal only, 8 with diagonals
speed: int = 3, # free: px/frame; grid-tween: px/frame slide
step: int = 16, # grid: cell size in px
facing_x: int = 0, facing_y: int = 1, # last-faced direction (for interact)
want_x: int = 0, want_y: int = 0, # intent (-1/0/1), input OR ai
cooldown: int = 0, move_cd: int = 8, # grid: frames between cell steps
tx: int = 0, ty: int = 0, sliding: int = 0, # grid-tween: target cell + state
policy: int = 0
}
event cancellable MoveRequested { e: int = 0, dx: int = 0, dy: int = 0 }
event TileEntered { e: int = 0, x: int = 0, y: int = 0 }
event Interacted { e: int = 0, target: int = 0 }
function mv_get(e: int, name: pointer) -> int {
let P = World.prop_id("Mover"); if P < 0 { return 0 }
let f = World.field_id(P, name); if f < 0 { return 0 }
return World.get(e, P, f)
}
function mv_set(e: int, name: pointer, v: int) -> void {
let P = World.prop_id("Mover"); if P < 0 { return }
let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) }
}
function mv_pos(e: int, name: pointer) -> int {
let P = World.prop_id("Position"); if P < 0 { return 0 }
let f = World.field_id(P, name); if f < 0 { return 0 }
return World.get(e, P, f)
}
function mv_pos_set(e: int, name: pointer, v: int) -> void {
let P = World.prop_id("Position"); if P < 0 { return }
let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) }
}
function mv_body_set(e: int, name: pointer, v: int) -> void {
let P = World.prop_id("Body"); if P < 0 { return }
let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) }
}
function mv_sign(a: int) -> int { if a > 0 { return 1 }; if a < 0 { return 0 - 1 }; return 0 }
# is the destination cell walkable? Uses the Solids tilemap when configured,
# otherwise the whole field is open.
function mv_walkable(nx: int, ny: int, step: int) -> int {
let ps = World.prop_id("Solids")
if ps < 0 { return 1 }
let se = World.query_next(ps, 0)
if se < 0 { return 1 }
let ft = World.field_id(ps, "tile")
let fw = World.field_id(ps, "wall")
if (ft < 0) or (fw < 0) { return 1 }
let ts = World.get(se, ps, ft)
if ts <= 0 { return 1 }
let wall = World.get(se, ps, fw)
if Grid.blocked(nx / ts, ny / ts, wall) { return 0 }
return 1
}
# ===========================================================================
# esys_mover (FixedUpdate) — one active branch per mode.
# ===========================================================================
@EngineSystem(Mover, FixedUpdate) function esys_mover() -> void {
let P = World.prop_id("Mover")
if P < 0 { return }
var e = World.query_next(P, 0)
while e >= 0 {
let mode = mv_get(e, "mode")
if mode == 1 { mover_free(e) }
else { if mode == 2 { mover_grid_tween(e) } else { mover_grid(e) } }
e = World.query_next(P, e + 1)
}
}
# clamp the intent to the allowed axes (4 = no diagonal: prefer the horizontal).
function mover_intent(e: int) -> int {
var wx = mv_get(e, "want_x")
var wy = mv_get(e, "want_y")
if (mv_get(e, "axes") == 4) and (wx != 0) and (wy != 0) { wy = 0 }
mv_set(e, "facing_x", 0); mv_set(e, "facing_y", 0)
if (wx != 0) or (wy != 0) { mv_set(e, "facing_x", mv_sign(wx)); mv_set(e, "facing_y", mv_sign(wy)) }
# pack signed x,y into one int for return: (wx+1)*4 + (wy+1)
return (mv_sign(wx) + 1) * 4 + (mv_sign(wy) + 1)
}
function mover_grid(e: int) -> void {
let cd = mv_get(e, "cooldown")
if cd > 0 { mv_set(e, "cooldown", cd - 1); return }
let packed = mover_intent(e)
let dx = packed / 4 - 1
let dy = packed - (packed / 4) * 4 - 1
if (dx == 0) and (dy == 0) { return }
let step = mv_get(e, "step")
let nx = mv_pos(e, "x") + dx * step
let ny = mv_pos(e, "y") + dy * step
if mv_walkable(nx, ny, step) == 0 { return }
if emit MoveRequested(e: e, dx: dx, dy: dy) != 0 { return }
mv_pos_set(e, "x", nx)
mv_pos_set(e, "y", ny)
mv_set(e, "cooldown", mv_get(e, "move_cd"))
emit TileEntered(e: e, x: nx, y: ny)
}
# grid-tween: same discrete logic, but slide the sprite between cells so it looks
# smooth while staying lockstep-discrete (the target cell is the source of truth).
function mover_grid_tween(e: int) -> void {
if mv_get(e, "sliding") == 1 {
let step = mv_get(e, "speed")
let tx = mv_get(e, "tx"); let ty = mv_get(e, "ty")
let px = mv_pos(e, "x"); let py = mv_pos(e, "y")
var nxp = px + mv_sign(tx - px) * step
var nyp = py + mv_sign(ty - py) * step
if mv_sign(tx - px) > 0 { if nxp > tx { nxp = tx } }
if mv_sign(tx - px) < 0 { if nxp < tx { nxp = tx } }
if mv_sign(ty - py) > 0 { if nyp > ty { nyp = ty } }
if mv_sign(ty - py) < 0 { if nyp < ty { nyp = ty } }
mv_pos_set(e, "x", nxp); mv_pos_set(e, "y", nyp)
if (nxp == tx) and (nyp == ty) { mv_set(e, "sliding", 0); emit TileEntered(e: e, x: tx, y: ty) }
return
}
let packed = mover_intent(e)
let dx = packed / 4 - 1
let dy = packed - (packed / 4) * 4 - 1
if (dx == 0) and (dy == 0) { return }
let step = mv_get(e, "step")
let nx = mv_pos(e, "x") + dx * step
let ny = mv_pos(e, "y") + dy * step
if mv_walkable(nx, ny, step) == 0 { return }
if emit MoveRequested(e: e, dx: dx, dy: dy) != 0 { return }
mv_set(e, "tx", nx); mv_set(e, "ty", ny); mv_set(e, "sliding", 1)
}
function mover_free(e: int) -> void {
let packed = mover_intent(e)
let dx = packed / 4 - 1
let dy = packed - (packed / 4) * 4 - 1
let sp = mv_get(e, "speed")
mv_body_set(e, "vx", dx * sp * RPG_Q)
mv_body_set(e, "vy", dy * sp * RPG_Q)
}
# ---------------------------------------------------------------------------
# Mover.* convenience
# ---------------------------------------------------------------------------
@Namespace(Mover) function mover_move(e: int, dx: int, dy: int) -> void { mv_set(e, "want_x", dx); mv_set(e, "want_y", dy) }
# interact: the entity one cell ahead in the facing direction (Interacted event).
@Namespace(Mover) function mover_interact(e: int) -> int {
let step = mv_get(e, "step")
let ax = mv_pos(e, "x") + mv_get(e, "facing_x") * step
let ay = mv_pos(e, "y") + mv_get(e, "facing_y") * step
let PP = World.prop_id("Position")
if PP < 0 { return 0 - 1 }
var o = World.query_next(PP, 0)
while o >= 0 {
if o != e {
let ox = World.get(o, PP, World.field_id(PP, "x"))
let oy = World.get(o, PP, World.field_id(PP, "y"))
let dx = ox - ax; let dy = oy - ay
var adx = dx; if adx < 0 { adx = 0 - adx }
var ady = dy; if ady < 0 { ady = 0 - ady }
if (adx < step) and (ady < step) { emit Interacted(e: e, target: o); return o }
}
o = World.query_next(PP, o + 1)
}
emit Interacted(e: e, target: 0 - 1)
return 0 - 1
}