# ============================================================================ # systems_light.ludic — the engine-owned 2D lighting render system (#47). # # This is the ECS-native shape the lighting follow-up asked for: a torch is just # an entity carrying `Light2D` (and optionally `Position`), a wall is an entity # carrying `Occluder`, and the engine runs the whole light pass for you at the # end of the Render phase — no `Light.point` / `Light.occlude` calls wired by # hand. It reuses the deterministic accumulation core shipped in #4 (light.ludic: # light_ambient / light_occlude / light_point), consuming components through the # by-name reflection ABI instead of imperative calls. # # Split from systems.ludic because it links against the light pass (light.ludic): # a game that uses SpriteAnim/Motion but no lights must not pull light_point in, # so esys_light2d lives in its own file, spliced only when Light2D / Occluder is # declared (parse.ludic). It is inserted into the Render phase after the game's # own draw handlers (emit_engine_systems_for_phase), so the scene is already on # the framebuffer when the light pass runs; it finishes by presenting the frame, # so an ECS-lit game leaves Screen.show to the engine. # # Component contracts: # property Ambient { color: int } # optional, one entity # property Light2D { x, y, radius, color, intensity } # intensity is 0..100 % # property Occluder { x, y, w, h } # A Light2D / Occluder reads its position from a `Position { x, y }` component on # the same entity when the entity carries one, else from its own x / y fields — # so "Position + Light2D" and a self-positioned Light2D both work. # ============================================================================ # read entity `e`'s world x: a `Position { x }` component wins when present, else # the component's own x field (fx) on prop `p`. Same idea for y below. function esys_pos_x(e: int, p: int, fx: int) -> int { let pp = World.prop_id("Position") if pp >= 0 { if World.has(e, pp) != 0 { let f = World.field_id(pp, "x") if f >= 0 { return World.get(e, pp, f) } } } if fx >= 0 { return World.get(e, p, fx) } return 0 } function esys_pos_y(e: int, p: int, fy: int) -> int { let pp = World.prop_id("Position") if pp >= 0 { if World.has(e, pp) != 0 { let f = World.field_id(pp, "y") if f >= 0 { return World.get(e, pp, f) } } } if fy >= 0 { return World.get(e, p, fy) } return 0 } function esys_light2d(rt_core_st: mut RtCoreState, rt_light_st: mut RtLightState) -> void { let pl = World.prop_id("Light2D") if pl < 0 { return } # 1. ambient — a single `Ambient { color }` entity modulates the whole scene # toward its tint (night / cave). Absent: the scene keeps its drawn brightness. let pa = World.prop_id("Ambient") if pa >= 0 { let af = World.field_id(pa, "color") let ae = World.query_next(pa, 0) if ae >= 0 { if af >= 0 { light_ambient(rt_core_st, World.get(ae, pa, af)) } } } # 2. occluders — re-register every `Occluder` as a shadow caster this frame. light_clear_occluders(rt_light_st) let po = World.prop_id("Occluder") if po >= 0 { let oxf = World.field_id(po, "x") let oyf = World.field_id(po, "y") let owf = World.field_id(po, "w") let ohf = World.field_id(po, "h") var oe = World.query_next(po, 0) while oe >= 0 { let ox = esys_pos_x(oe, po, oxf) let oy = esys_pos_y(oe, po, oyf) var ow = 0 var oh = 0 if owf >= 0 { ow = World.get(oe, po, owf) } if ohf >= 0 { oh = World.get(oe, po, ohf) } light_occlude(rt_light_st, ox, oy, ow, oh) oe = World.query_next(po, oe + 1) } } # 3. lights — additively accumulate each `Light2D` as a radial glow, cut by the # occluders registered above (hard shadows). Beyond the core five fields, a # Light2D may carry optional tier fields, each defaulting off when absent so an # older five-field component still lights exactly as before: # direction, spread — a cone/spot (spread < 0 or absent = omnidirectional) # falloff — brightness ramp exponent (1 = linear, absent = 1) # softness — penumbra radius in px (0 / absent = hard shadows) # gel — outer rim colour for a colour cookie (< 0 / absent = off) let lxf = World.field_id(pl, "x") let lyf = World.field_id(pl, "y") let lrf = World.field_id(pl, "radius") let lcf = World.field_id(pl, "color") let lif = World.field_id(pl, "intensity") let ldf = World.field_id(pl, "direction") let lsf = World.field_id(pl, "spread") let lff = World.field_id(pl, "falloff") let lkf = World.field_id(pl, "softness") let lgf = World.field_id(pl, "gel") var e = World.query_next(pl, 0) while e >= 0 { let x = esys_pos_x(e, pl, lxf) let y = esys_pos_y(e, pl, lyf) var radius = 0 if lrf >= 0 { radius = World.get(e, pl, lrf) } var color = 16777215 # 0xFFFFFF (white) default if lcf >= 0 { color = World.get(e, pl, lcf) } var energy = fixed(1) # full intensity default if lif >= 0 { energy = fixed(World.get(e, pl, lif)) / fixed(100) } # tier controls: apply per light, resetting to defaults each time so one # cone/soft light does not bleed its settings onto the next. var falloff = 1 if lff >= 0 { falloff = World.get(e, pl, lff) } light_set_falloff(rt_light_st, falloff) var soft = 0 if lkf >= 0 { soft = World.get(e, pl, lkf) } light_set_soft(rt_light_st, soft) if lgf >= 0 { light_set_gel(rt_light_st, World.get(e, pl, lgf)) } else { light_clear_gel(rt_light_st) } var spread = -1 if lsf >= 0 { spread = World.get(e, pl, lsf) } var direction = 0 if ldf >= 0 { direction = World.get(e, pl, ldf) } if spread >= 0 { light_spot(rt_core_st, rt_light_st, x, y, radius, color, energy, direction, spread) } else { light_point(rt_core_st, rt_light_st, x, y, radius, color, energy) } e = World.query_next(pl, e + 1) } # 4. present — the engine owns the flip for an ECS-lit frame. Screen.show() }