Completes the transform/state-based rendering #23 tracked as blocked on new renderer state. All of it threads through the two framebuffer chokepoints every draw primitive already funnels through (rt_put_px / rt_fill_rect), so one place gives the whole draw API a camera, a clip rect, and a blend mode. Defaults are neutral — camera (0,0), clip = full screen, blend = replace — so every existing golden render is byte-identical (the 60+ render tests still pass unchanged). New renderer state (runtime/native/core.ludic): - Screen.camera(x, y) / Camera.set(x, y) world-space draw offset; a world point draws at (wx-x, wy-y). Moves everything — reset to (0,0) for a HUD. - Camera.follow(x, y, lerp) ease the offset toward centring a target (fixed lerp 0..1) - Camera.shake(amount) +/- amount jitter from the seeded RNG (replay shakes identically); 0 clears - Screen.clip(x,y,w,h) / clip_reset() screen-space clip rectangle - Screen.blend_mode(m) 0 = replace, 1 = additive (clamped) New primitives: - Screen.oval(x, y, rx, ry, color) axis-aligned ellipse outline (midpoint) - Screen.measure_text(text) -> int advance width in the 5x7 font - Screen.pixel(x, y) -> int read a framebuffer pixel (0x00RRGGBB) Everything stays integer and deterministic (the camera, shake, and blend all reproduce exactly under identical inputs), so headless renders remain diffable. Camera.follow interpolates in the fixed domain (fixed*fixed then floor) to avoid the int*fixed coercion trap. Screen.pixel makes the whole surface testable by reading rendered pixels back: examples/library/render.ludic asserts 18 cases — pixel round-trip, camera and Camera.set/follow offsets, clip in/out + reset, additive blend with 255 clamp, oval extremes vs hollow centre, and text measurement — all verified against the actual framebuffer, not just that the call compiled. Wired into x test (now 65 passed). Docs: 7 new Screen pages + a Camera section with 3 pages, inventory/coverage green. Seed reseeded; the C-free bootstrap fixpoint holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
629 lines
18 KiB
Text
629 lines
18 KiB
Text
# ============================================================================
|
|
# runtime/native/core.ludic — the Ludic runtime, written in Ludic.
|
|
#
|
|
# This is what the native backend links instead of a C runtime. Everything a
|
|
# game touches through a builtin — the framebuffer, rectangles, text, the
|
|
# registers, the RNG, input, the frame dump — is implemented right here in
|
|
# Ludic and compiled to machine code by ludicc like any other Ludic source.
|
|
#
|
|
# Underneath it there is exactly one layer, and it is not C: the compiler
|
|
# intrinsics (mem_alloc, peek8/poke8, peek32/poke32, file_open/file_write,
|
|
# read_byte, …) lower to direct calls into the platform's C ABI — malloc,
|
|
# fwrite, getchar. That ABI is the operating system's interface, the same floor
|
|
# Rust and Swift stand on. No C source is compiled at any point in the build.
|
|
#
|
|
# A builtin `clear(c)` in a game resolves to `rt_clear(c)` here; that is the
|
|
# whole protocol. Swap this file and you have swapped the runtime.
|
|
#
|
|
# This file is a fragment: ludicc splices it into every native build. It is not
|
|
# a `game`/`module` block of its own.
|
|
# ============================================================================
|
|
|
|
# ---- state ----------------------------------------------------------------
|
|
var rt_fb: words = null # framebuffer, one i32 (0x00RRGGBB) per pixel
|
|
var rt_fbw: int = 320
|
|
var rt_fbh: int = 240
|
|
var rt_regs: words = null # the 64 general-purpose game registers
|
|
var rt_rng: int = 305419896 # xorshift32 state
|
|
var rt_alive: int = 1 # platform still running?
|
|
|
|
# ---- renderer state (camera / clip / blend) -------------------------------
|
|
# A world-space camera offset, a clip rectangle, and a blend mode threaded
|
|
# through the two framebuffer chokepoints (rt_put_px / rt_fill_rect); every
|
|
# draw primitive funnels through one of those, so they all inherit the state.
|
|
# The defaults are neutral — camera (0,0), clip = full screen, blend = replace —
|
|
# so a game that never touches them renders exactly as before. The camera moves
|
|
# everything drawn; reset it to (0,0) to draw a fixed HUD over the world.
|
|
var rt_cam_x: int = 0 # camera base offset (world -> screen: subtracted)
|
|
var rt_cam_y: int = 0
|
|
var rt_shake_x: int = 0 # transient screen-shake offset, added to the base
|
|
var rt_shake_y: int = 0
|
|
var rt_clip_x0: int = 0 # clip rectangle in screen space (half-open)
|
|
var rt_clip_y0: int = 0
|
|
var rt_clip_x1: int = 320
|
|
var rt_clip_y1: int = 240
|
|
var rt_blend: int = 0 # 0 = replace, 1 = additive
|
|
|
|
# 5x7 glyphs for ASCII 32..90, 7 rows per glyph, each row a 5-bit mask stored
|
|
# biased by '0' so the whole font is one printable string literal.
|
|
function rt_font() -> string {
|
|
return "00000004444404000000000000000000000IJ4:FC000000000000000000000000000000E>O>E0044O4400000448000O000000004012448@@>ACEIA>4<4444>>A168@ON11>11N26:BO22O@N11A>>@@NAA>O124888>AA>AA>>AA?11>04004000000000024842000O0O000842480>A164040000000>AAOAAANAANAAN>A@@@A>LBAAABLO@@L@@OO@@L@@@>A@GAA>AAAOAAA>44444>7222BB<ABDHDBA@@@@@@OAKEEAAAAIEECAA>AAAAA>NAAN@@@>AAAEB=NAANDBA>A@>1A>O444444AAAAAA>AAAAA:4AAAEEKAAA:4:AAAA:4444O1248@O"
|
|
}
|
|
|
|
function rt_init() -> void {
|
|
rt_fb = words(320 * 240)
|
|
rt_regs = words(64)
|
|
fill(rt_regs, 0, 64 * 4)
|
|
rt_map = bytes(96 * 64)
|
|
fill(rt_map, 32, 96 * 64)
|
|
rt_clip_x1 = rt_fbw
|
|
rt_clip_y1 = rt_fbh
|
|
rt_statusbuf = bytes(96)
|
|
rt_statusbuf[0] = 0
|
|
rt_image_init()
|
|
rt_tt_init()
|
|
rt_ui_init()
|
|
rt_clear(0)
|
|
if is_windowed() {
|
|
win_open(rt_fbw, rt_fbh, 3, game_title())
|
|
}
|
|
}
|
|
|
|
function rt_shutdown() -> void {
|
|
if is_windowed() {
|
|
win_close()
|
|
return
|
|
}
|
|
rt_dump_ppm("build/out.ppm")
|
|
}
|
|
|
|
# ---- framebuffer ----------------------------------------------------------
|
|
function rt_screen_w() -> int { return rt_fbw }
|
|
function rt_screen_h() -> int { return rt_fbh }
|
|
|
|
function rt_clear(c: int) -> void {
|
|
let n = rt_fbw * rt_fbh
|
|
for i in 0 .. n {
|
|
rt_fb[i] = c
|
|
}
|
|
}
|
|
|
|
# additive blend of src over dst, per channel, clamped to 255.
|
|
function rt_blend_add(dst: int, src: int) -> int {
|
|
let r = min(255, ((dst >> 16) & 255) + ((src >> 16) & 255))
|
|
let g = min(255, ((dst >> 8) & 255) + ((src >> 8) & 255))
|
|
let b = min(255, (dst & 255) + (src & 255))
|
|
return (r << 16) | (g << 8) | b
|
|
}
|
|
|
|
# the low-level plot: apply the camera (+ shake) offset, reject anything outside
|
|
# the clip rectangle or the framebuffer, then write or additively blend.
|
|
function rt_put_px(x: int, y: int, c: int) -> void {
|
|
let sx = x - rt_cam_x - rt_shake_x
|
|
let sy = y - rt_cam_y - rt_shake_y
|
|
if sx < rt_clip_x0 { return }
|
|
if sy < rt_clip_y0 { return }
|
|
if sx >= rt_clip_x1 { return }
|
|
if sy >= rt_clip_y1 { return }
|
|
if sx < 0 { return }
|
|
if sy < 0 { return }
|
|
if sx >= rt_fbw { return }
|
|
if sy >= rt_fbh { return }
|
|
let idx = sy * rt_fbw + sx
|
|
if rt_blend == 1 { rt_fb[idx] = rt_blend_add(rt_fb[idx], c) }
|
|
else { rt_fb[idx] = c }
|
|
}
|
|
|
|
function rt_fill_rect(x: int, y: int, w: int, h: int, c: int) -> void {
|
|
let ox = x - rt_cam_x - rt_shake_x
|
|
let oy = y - rt_cam_y - rt_shake_y
|
|
let x0 = max(max(0, rt_clip_x0), ox)
|
|
let y0 = max(max(0, rt_clip_y0), oy)
|
|
let x1 = min(min(rt_fbw, rt_clip_x1), ox + w)
|
|
let y1 = min(min(rt_fbh, rt_clip_y1), oy + h)
|
|
var j = y0
|
|
while j < y1 {
|
|
let row = j * rt_fbw
|
|
var i = x0
|
|
while i < x1 {
|
|
if rt_blend == 1 { rt_fb[row + i] = rt_blend_add(rt_fb[row + i], c) }
|
|
else { rt_fb[row + i] = c }
|
|
i = i + 1
|
|
}
|
|
j = j + 1
|
|
}
|
|
}
|
|
|
|
function rt_frame_rect(x: int, y: int, w: int, h: int, c: int) -> void {
|
|
rt_fill_rect(x, y, w, 1, c)
|
|
rt_fill_rect(x, y + h - 1, w, 1, c)
|
|
rt_fill_rect(x, y, 1, h, c)
|
|
rt_fill_rect(x + w - 1, y, 1, h, c)
|
|
}
|
|
|
|
# A straight line by Bresenham's algorithm — integer only, any direction.
|
|
function rt_line(x0: int, y0: int, x1: int, y1: int, c: int) -> void {
|
|
var x = x0; var y = y0
|
|
let dx = abs(x1 - x0); let dy = 0 - abs(y1 - y0)
|
|
var sx = 0 - 1; if x0 < x1 { sx = 1 }
|
|
var sy = 0 - 1; if y0 < y1 { sy = 1 }
|
|
var err = dx + dy
|
|
while true {
|
|
rt_put_px(x, y, c)
|
|
if (x == x1) and (y == y1) { return }
|
|
let e2 = 2 * err
|
|
if e2 >= dy { err = err + dy; x = x + sx }
|
|
if e2 <= dx { err = err + dx; y = y + sy }
|
|
}
|
|
}
|
|
|
|
# A circle outline by the midpoint algorithm (eight-way symmetry).
|
|
function rt_circle(cx: int, cy: int, r: int, c: int) -> void {
|
|
if r < 0 { return }
|
|
var x = r; var y = 0; var err = 1 - r
|
|
while x >= y {
|
|
rt_put_px(cx + x, cy + y, c); rt_put_px(cx + y, cy + x, c)
|
|
rt_put_px(cx - y, cy + x, c); rt_put_px(cx - x, cy + y, c)
|
|
rt_put_px(cx - x, cy - y, c); rt_put_px(cx - y, cy - x, c)
|
|
rt_put_px(cx + y, cy - x, c); rt_put_px(cx + x, cy - y, c)
|
|
y = y + 1
|
|
if err < 0 { err = err + 2 * y + 1 }
|
|
else { x = x - 1; err = err + 2 * (y - x) + 1 }
|
|
}
|
|
}
|
|
|
|
# A filled disc — one horizontal span per row, width from the circle equation.
|
|
function rt_fill_circle(cx: int, cy: int, r: int, c: int) -> void {
|
|
if r < 0 { return }
|
|
let r2 = r * r
|
|
var dy = 0 - r
|
|
while dy <= r {
|
|
var dx = 0
|
|
while (dx + 1) * (dx + 1) + dy * dy <= r2 { dx = dx + 1 }
|
|
rt_fill_rect(cx - dx, cy + dy, 2 * dx + 1, 1, c)
|
|
dy = dy + 1
|
|
}
|
|
}
|
|
|
|
# A triangle outline — three lines.
|
|
function rt_triangle(x0: int, y0: int, x1: int, y1: int, x2: int, y2: int, c: int) -> void {
|
|
rt_line(x0, y0, x1, y1, c)
|
|
rt_line(x1, y1, x2, y2, c)
|
|
rt_line(x2, y2, x0, y0, c)
|
|
}
|
|
|
|
# A filled triangle — bounding-box scan with an edge-sign inside test.
|
|
function rt_fill_triangle(x0: int, y0: int, x1: int, y1: int, x2: int, y2: int, c: int) -> void {
|
|
let minx = min(x0, min(x1, x2)); let maxx = max(x0, max(x1, x2))
|
|
let miny = min(y0, min(y1, y2)); let maxy = max(y0, max(y1, y2))
|
|
var py = miny
|
|
while py <= maxy {
|
|
var px = minx
|
|
while px <= maxx {
|
|
let d0 = (x1 - x0) * (py - y0) - (y1 - y0) * (px - x0)
|
|
let d1 = (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1)
|
|
let d2 = (x0 - x2) * (py - y2) - (y0 - y2) * (px - x2)
|
|
let neg = (d0 < 0) or (d1 < 0) or (d2 < 0)
|
|
let pos = (d0 > 0) or (d1 > 0) or (d2 > 0)
|
|
if not (neg and pos) { rt_put_px(px, py, c) }
|
|
px = px + 1
|
|
}
|
|
py = py + 1
|
|
}
|
|
}
|
|
|
|
# the four-way symmetric points of an ellipse centred at (cx, cy).
|
|
function rt_oval_pts(cx: int, cy: int, x: int, y: int, c: int) -> void {
|
|
rt_put_px(cx + x, cy + y, c)
|
|
rt_put_px(cx - x, cy + y, c)
|
|
rt_put_px(cx + x, cy - y, c)
|
|
rt_put_px(cx - x, cy - y, c)
|
|
}
|
|
|
|
# An axis-aligned ellipse outline by the midpoint algorithm — integer only,
|
|
# radii rx (horizontal) and ry (vertical). rx == ry draws a circle.
|
|
function rt_oval(x: int, y: int, rx: int, ry: int, c: int) -> void {
|
|
if rx <= 0 { return }
|
|
if ry <= 0 { return }
|
|
let rx2 = rx * rx
|
|
let ry2 = ry * ry
|
|
let two_rx2 = 2 * rx2
|
|
let two_ry2 = 2 * ry2
|
|
var ex = 0
|
|
var ey = ry
|
|
var px = 0
|
|
var py = two_rx2 * ey
|
|
rt_oval_pts(x, y, ex, ey, c)
|
|
var p = ry2 - rx2 * ry + rx2 / 4 # region 1
|
|
while px < py {
|
|
ex = ex + 1
|
|
px = px + two_ry2
|
|
if p < 0 { p = p + ry2 + px }
|
|
else { ey = ey - 1; py = py - two_rx2; p = p + ry2 + px - py }
|
|
rt_oval_pts(x, y, ex, ey, c)
|
|
}
|
|
p = ry2 * (ex * 2 + 1) * (ex * 2 + 1) / 4 + rx2 * (ey - 1) * (ey - 1) - rx2 * ry2 # region 2
|
|
while ey > 0 {
|
|
ey = ey - 1
|
|
py = py - two_rx2
|
|
if p > 0 { p = p + rx2 - py }
|
|
else { ex = ex + 1; px = px + two_ry2; p = p + rx2 - py + px }
|
|
rt_oval_pts(x, y, ex, ey, c)
|
|
}
|
|
}
|
|
|
|
# Read a framebuffer pixel in screen space (0x00RRGGBB), or 0 if out of bounds.
|
|
# Unlike the plot path this ignores the camera — it reads the actual screen.
|
|
function rt_get_px(x: int, y: int) -> int {
|
|
if x < 0 { return 0 }
|
|
if y < 0 { return 0 }
|
|
if x >= rt_fbw { return 0 }
|
|
if y >= rt_fbh { return 0 }
|
|
return rt_fb[y * rt_fbw + x]
|
|
}
|
|
|
|
# Advance width (pixels) of `text` in the built-in 5x7 font at scale 1: 6 per
|
|
# glyph (5 wide + 1 gap), matching rt_text's cursor step.
|
|
function rt_measure_text(text: string) -> int {
|
|
var i = 0
|
|
while text[i] != 0 { i = i + 1 }
|
|
return i * 6
|
|
}
|
|
|
|
# ---- camera / clip / blend controls ---------------------------------------
|
|
# Set the world-space camera offset (a world point (wx,wy) draws at
|
|
# (wx-x, wy-y)). Reset to (0,0) to draw a fixed HUD.
|
|
function rt_camera(x: int, y: int) -> void { rt_cam_x = x; rt_cam_y = y }
|
|
|
|
# Ease the camera so (x,y) drifts toward the screen centre by `lerp` (a fixed in
|
|
# 0..1): 0 keeps it still, 65536 (1.0) snaps it centred. Deterministic.
|
|
function rt_camera_follow(x: int, y: int, lerp: fixed) -> void {
|
|
let tx = x - rt_fbw / 2
|
|
let ty = y - rt_fbh / 2
|
|
let sx = fixed(tx - rt_cam_x) * lerp # fixed * fixed, then floor to whole pixels
|
|
let sy = fixed(ty - rt_cam_y) * lerp
|
|
rt_cam_x = rt_cam_x + floor(sx)
|
|
rt_cam_y = rt_cam_y + floor(sy)
|
|
}
|
|
|
|
# Add a random screen shake of up to +/- amount pixels, drawn from the seeded
|
|
# RNG (so a replay shakes identically). Call each frame with a decaying amount;
|
|
# amount <= 0 clears it.
|
|
function rt_camera_shake(amount: int) -> void {
|
|
if amount <= 0 { rt_shake_x = 0; rt_shake_y = 0; return }
|
|
rt_shake_x = rt_rng_range(0 - amount, amount)
|
|
rt_shake_y = rt_rng_range(0 - amount, amount)
|
|
}
|
|
|
|
# Restrict drawing to a screen-space rectangle (x, y, width, height).
|
|
function rt_clip(x: int, y: int, width: int, height: int) -> void {
|
|
rt_clip_x0 = x
|
|
rt_clip_y0 = y
|
|
rt_clip_x1 = x + width
|
|
rt_clip_y1 = y + height
|
|
}
|
|
|
|
# Reset the clip rectangle to the whole framebuffer.
|
|
function rt_clip_reset() -> void {
|
|
rt_clip_x0 = 0
|
|
rt_clip_y0 = 0
|
|
rt_clip_x1 = rt_fbw
|
|
rt_clip_y1 = rt_fbh
|
|
}
|
|
|
|
# Select the pixel blend mode: 0 = replace (default), 1 = additive.
|
|
function rt_blend_mode(mode: int) -> void { rt_blend = mode }
|
|
|
|
# Windowed: hand the framebuffer to the platform layer, which blits it into
|
|
# the view. Headless: nothing to do until shutdown writes the last frame out.
|
|
function rt_present() -> void {
|
|
if is_windowed() {
|
|
win_present(rt_fb, rt_fbw, rt_fbh)
|
|
}
|
|
}
|
|
|
|
# ---- text -----------------------------------------------------------------
|
|
function rt_glyph(x: int, y: int, ch: int, colour: int, sc: int) -> void {
|
|
var c = ch
|
|
if c >= 97 {
|
|
if c <= 122 { c = c - 32 }
|
|
}
|
|
if c < 32 { return }
|
|
if c > 90 { return }
|
|
let base = (c - 32) * 7
|
|
let font = rt_font()
|
|
for row in 0 .. 7 {
|
|
let bits = font[base + row] - 48
|
|
var b = bits
|
|
for cc in 0 .. 5 {
|
|
let on = b / 16
|
|
if on == 1 {
|
|
rt_fill_rect(x + cc * sc, y + row * sc, sc, sc, colour)
|
|
}
|
|
b = (b - on * 16) * 2
|
|
}
|
|
}
|
|
}
|
|
|
|
function rt_text(x: int, y: int, s: string, colour: int, sc: int) -> void {
|
|
var i = 0
|
|
var cx = x
|
|
var ch = s[0]
|
|
while ch != 0 {
|
|
rt_glyph(cx, y, ch, colour, sc)
|
|
cx = cx + 6 * sc
|
|
i = i + 1
|
|
ch = s[i]
|
|
}
|
|
}
|
|
|
|
function rt_text_int(x: int, y: int, n: int, colour: int, sc: int) -> void {
|
|
if n == 0 {
|
|
rt_glyph(x, y, 48, colour, sc)
|
|
return
|
|
}
|
|
var v = n
|
|
var cx = x
|
|
if v < 0 {
|
|
rt_glyph(cx, y, 45, colour, sc)
|
|
cx = cx + 6 * sc
|
|
v = 0 - v
|
|
}
|
|
var digits = 0
|
|
var t = v
|
|
while t > 0 {
|
|
digits = digits + 1
|
|
t = t / 10
|
|
}
|
|
var p = digits
|
|
while p > 0 {
|
|
var div = 1
|
|
for k in 1 .. p {
|
|
div = div * 10
|
|
}
|
|
rt_glyph(cx, y, 48 + (v / div) % 10, colour, sc)
|
|
cx = cx + 6 * sc
|
|
p = p - 1
|
|
}
|
|
}
|
|
|
|
# ---- registers ------------------------------------------------------------
|
|
function rt_reg(i: int) -> int {
|
|
if i < 0 { return 0 }
|
|
if i >= 64 { return 0 }
|
|
return rt_regs[i]
|
|
}
|
|
|
|
function rt_set_reg(i: int, v: int) -> void {
|
|
if i < 0 { return }
|
|
if i >= 64 { return }
|
|
rt_regs[i] = v
|
|
}
|
|
|
|
# ---- rng (xorshift32) -----------------------------------------------------
|
|
function rt_seed(s: int) -> void {
|
|
if s == 0 {
|
|
rt_rng = 305419896
|
|
return
|
|
}
|
|
rt_rng = s
|
|
}
|
|
|
|
# xorshift32 (Marsaglia). Runs on the raw 32-bit pattern, so the sign bit is
|
|
# masked off only when a caller asks for a number.
|
|
function rt_next_rand() -> int {
|
|
var x = rt_rng
|
|
x = (x ^ (x << 13))
|
|
x = (x ^ (x >> 17))
|
|
x = (x ^ (x << 5))
|
|
rt_rng = x
|
|
return (x & 2147483647)
|
|
}
|
|
|
|
function rt_rng_range(lo: int, hi: int) -> int {
|
|
if hi <= lo { return lo }
|
|
return lo + rt_next_rand() % (hi - lo + 1)
|
|
}
|
|
|
|
function rt_rng_chance(pct: int) -> bool {
|
|
return rt_next_rand() % 100 < pct
|
|
}
|
|
|
|
# a deterministic fixed-point value in [0, 1) — the raw 0..65535 is exactly the
|
|
# Q16.16 fraction (fixed and int share the i32 representation).
|
|
function rt_rng_value() -> fixed {
|
|
return rt_rng_range(0, 65535)
|
|
}
|
|
|
|
# a deterministic integer in [0, max) — 0 when max <= 0
|
|
function rt_rng_int(max: int) -> int {
|
|
if max <= 0 { return 0 }
|
|
return rt_rng_range(0, max - 1)
|
|
}
|
|
|
|
# a deterministic +1 or -1
|
|
function rt_rng_sign() -> int {
|
|
if rt_rng_chance(50) { return 1 }
|
|
return 0 - 1
|
|
}
|
|
|
|
# ---- platform: input ------------------------------------------------------
|
|
function rt_poll() -> int {
|
|
if is_windowed() {
|
|
return win_poll()
|
|
}
|
|
let c = read_char()
|
|
if c < 0 {
|
|
rt_alive = 0
|
|
return 0
|
|
}
|
|
if c == 113 { # 'q' quits, as in the headless C platform
|
|
rt_alive = 0
|
|
}
|
|
return c
|
|
}
|
|
|
|
function rt_running() -> bool {
|
|
if is_windowed() {
|
|
return win_running()
|
|
}
|
|
return rt_alive
|
|
}
|
|
|
|
# ---- writing the frame out ------------------------------------------------
|
|
function rt_put_str(buf: pointer, at: int, s: string) -> int {
|
|
var i = 0
|
|
var n = at
|
|
var ch = s[0]
|
|
while ch != 0 {
|
|
buf[n] = ch
|
|
n = n + 1
|
|
i = i + 1
|
|
ch = s[i]
|
|
}
|
|
return n
|
|
}
|
|
|
|
function rt_put_int(buf: pointer, at: int, v: int) -> int {
|
|
if v == 0 {
|
|
buf[at] = 48
|
|
return at + 1
|
|
}
|
|
var digits = 0
|
|
var t = v
|
|
while t > 0 {
|
|
digits = digits + 1
|
|
t = t / 10
|
|
}
|
|
var n = at
|
|
var p = digits
|
|
while p > 0 {
|
|
var div = 1
|
|
for k in 1 .. p {
|
|
div = div * 10
|
|
}
|
|
buf[n] = 48 + (v / div) % 10
|
|
n = n + 1
|
|
p = p - 1
|
|
}
|
|
return n
|
|
}
|
|
|
|
function rt_dump_ppm(path: string) -> void {
|
|
let f = file_open(path, "wb")
|
|
if (f == null) { return }
|
|
|
|
let hdr = bytes(64)
|
|
var n = rt_put_str(hdr, 0, "P6\n")
|
|
n = rt_put_int(hdr, n, rt_fbw)
|
|
n = rt_put_str(hdr, n, " ")
|
|
n = rt_put_int(hdr, n, rt_fbh)
|
|
n = rt_put_str(hdr, n, "\n255\n")
|
|
file_write(f, hdr, n)
|
|
|
|
let px = rt_fbw * rt_fbh
|
|
let buf = bytes(px * 3)
|
|
for i in 0 .. px {
|
|
let c = rt_fb[i]
|
|
buf[i * 3] = (c / 65536) % 256
|
|
buf[i * 3 + 1] = (c / 256) % 256
|
|
buf[i * 3 + 2] = c % 256
|
|
}
|
|
file_write(f, buf, px * 3)
|
|
file_close(f)
|
|
free(buf)
|
|
free(hdr)
|
|
}
|
|
|
|
import "inflate.ludic"
|
|
import "image.ludic"
|
|
import "truetype.ludic"
|
|
import "ui.ludic"
|
|
import "grid.ludic"
|
|
|
|
# ---- tilemap --------------------------------------------------------------
|
|
# A character grid the game paints with map_row() and reads with tile(). Stored
|
|
# as one byte per cell in a flat buffer; out-of-bounds reads answer '#' so a
|
|
# caller can treat the edge of the world as wall without special-casing it.
|
|
var rt_map: pointer = null
|
|
var rt_mapw: int = 0
|
|
var rt_maph: int = 0
|
|
|
|
function rt_map_size(w: int, h: int) -> void {
|
|
rt_mapw = clamp(w, 0, 96)
|
|
rt_maph = clamp(h, 0, 64)
|
|
fill(rt_map, 32, 96 * 64)
|
|
}
|
|
|
|
function rt_map_row(y: int, s: string) -> void {
|
|
if y < 0 { return }
|
|
if y >= 64 { return }
|
|
var x = 0
|
|
var ch = s[0]
|
|
while ch != 0 {
|
|
if x >= 96 { return }
|
|
rt_map[y * 96 + x] = ch
|
|
x = x + 1
|
|
ch = s[x]
|
|
}
|
|
}
|
|
|
|
function rt_tile(x: int, y: int) -> int {
|
|
if x < 0 { return 35 }
|
|
if y < 0 { return 35 }
|
|
if x >= rt_mapw { return 35 }
|
|
if y >= rt_maph { return 35 }
|
|
return rt_map[y * 96 + x]
|
|
}
|
|
|
|
# ---- status line ----------------------------------------------------------
|
|
# One persistent string of feedback/dialogue, copied into runtime-owned memory
|
|
# so it survives whatever the caller does with the original.
|
|
var rt_statusbuf: pointer = null
|
|
|
|
function rt_status(s: string) -> void {
|
|
var i = 0
|
|
var ch = s[0]
|
|
while ch != 0 {
|
|
if i >= 95 { ch = 0 }
|
|
if ch != 0 {
|
|
rt_statusbuf[i] = ch
|
|
i = i + 1
|
|
ch = s[i]
|
|
}
|
|
}
|
|
rt_statusbuf[i] = 0
|
|
}
|
|
|
|
function rt_status_text() -> pointer {
|
|
return rt_statusbuf
|
|
}
|
|
|
|
# ---- snapshot: the runtime serialises its own half ------------------------
|
|
# The compiler writes the ECS (entities, components, archetype kinds) because
|
|
# only it knows their shape. Everything below belongs to the runtime, so the
|
|
# runtime writes it — same order both ways.
|
|
function rt_save_state(f: pointer) -> void {
|
|
let w: words = bytes(16)
|
|
w[0] = rt_rng
|
|
w[1] = rt_mapw
|
|
w[2] = rt_maph
|
|
w[3] = rt_alive
|
|
file_write(f, w, 16)
|
|
file_write(f, rt_regs, 64 * 4)
|
|
file_write(f, rt_map, 96 * 64)
|
|
file_write(f, rt_statusbuf, 96)
|
|
free(w)
|
|
}
|
|
|
|
function rt_load_state(f: pointer) -> void {
|
|
let w: words = bytes(16)
|
|
file_read(f, w, 16)
|
|
rt_rng = w[0]
|
|
rt_mapw = w[1]
|
|
rt_maph = w[2]
|
|
file_read(f, rt_regs, 64 * 4)
|
|
file_read(f, rt_map, 96 * 64)
|
|
file_read(f, rt_statusbuf, 96)
|
|
free(w)
|
|
}
|