feat(rendering): add Screen.camera/clip/blend_mode/oval + Camera.* + Screen.pixel (#23)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 17s
ci / build-and-test (push) Successful in 1m12s
commit-lint / conventional-commits (push) Successful in 4s
docs / build-and-deploy (push) Successful in 18s

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>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-31 13:43:01 +03:00
parent 12f2dbe958
commit 31cfbc2465
16 changed files with 8383 additions and 7183 deletions

View file

@ -0,0 +1,7 @@
---
id: camera
title: Camera
order: 32
---
A world-space camera — a draw offset threaded through the render path (the same offset <a href="screen-camera"><code>Screen.camera</code></a> sets). <a href="camera-set"><code>Camera.set</code></a> places it, <a href="camera-follow"><code>Camera.follow</code></a> eases it toward a target, and <a href="camera-shake"><code>Camera.shake</code></a> jitters it from the seeded RNG for impact and explosions. Everything is integer and deterministic — driven off the same seed and inputs, a replay reproduces the exact camera path, shake included. The camera moves everything drawn; reset it to <code>(0, 0)</code> to draw a fixed HUD.

View file

@ -0,0 +1,29 @@
---
id: camera-follow
name: Camera.follow
category: camera
kind: namespace-method
tokens: Camera.follow
sig: Camera.follow(x, y, lerp)
tip: Ease the camera toward centring a target point.
order: 2
ns: Camera
member: follow
---
Moves the camera a fraction <code>lerp</code> of the way toward centring the world point <code>(x, y)</code> on screen. <code>lerp</code> is a <code>fixed</code> in <code>0.0</code>..<code>1.0</code>: <code>0</code> holds still, a small value trails smoothly behind a moving target, <code>1.0</code> snaps it centred. Call it each frame with the target's position for a classic smooth-follow camera. Deterministic.
Parameters:
- `x`, `y` — the world point to centre on (usually the player)
- `lerp` — how far to move this frame (a `fixed`, 0..1)
```ludic
program Demo {
property Position { x: int = 0, y: int = 0 }
model Player { Position }
handler DrawWorld phase Render {
Camera.follow(Position.x, Position.y, fixed(1) / fixed(8)) # smooth trail
Screen.show()
}
}
```

View file

@ -0,0 +1,25 @@
---
id: camera-set
name: Camera.set
category: camera
kind: namespace-method
tokens: Camera.set
sig: Camera.set(x, y)
tip: Place the camera at a world-space offset.
order: 1
ns: Camera
member: set
---
Sets the camera offset directly: a world point <code>(wx, wy)</code> draws on screen at <code>(wx - x, wy - y)</code>. The same control as <a href="screen-camera"><code>Screen.camera</code></a>, under the <code>Camera</code> namespace. Use it to snap the view, or as the base that <a href="camera-follow"><code>Camera.follow</code></a> and <a href="camera-shake"><code>Camera.shake</code></a> build on.
```ludic
program Demo {
property Position { x: int = 0, y: int = 0 }
model Player { Position }
handler DrawWorld phase Render {
Camera.set(Position.x - 160, Position.y - 120)
Screen.show()
}
}
```

View file

@ -0,0 +1,29 @@
---
id: camera-shake
name: Camera.shake
category: camera
kind: namespace-method
tokens: Camera.shake
sig: Camera.shake(amount)
tip: Jitter the camera by up to +/- amount pixels (seeded RNG).
order: 3
ns: Camera
member: shake
---
Adds a random screen shake of up to <code>+/- amount</code> pixels on top of the camera's base offset, drawn from the seeded RNG so a replay shakes identically. Call it each frame with a decaying <code>amount</code> for a hit or explosion; <code>Camera.shake(0)</code> clears it. It stacks on <a href="camera-set"><code>Camera.set</code></a> / <a href="camera-follow"><code>Camera.follow</code></a>, so follow and shake compose.
Parameters:
- `amount` — the maximum shake magnitude in pixels (0 clears it)
```ludic
program Demo {
property Hit { timer: int = 0 }
model Cam { Hit }
handler DrawWorld phase Render {
if Hit.timer > 0 { Camera.shake(Hit.timer) }
else { Camera.shake(0) }
Screen.show()
}
}
```

View file

@ -0,0 +1,26 @@
---
id: screen-blend_mode
name: Screen.blend_mode
category: screen
kind: namespace-method
tokens: Screen.blend_mode
sig: Screen.blend_mode(mode)
tip: Choose replace or additive pixel blending.
order: 21
ns: Screen
member: blend_mode
---
Selects how drawn pixels combine with the framebuffer: <code>0</code> replaces (the default), <code>1</code> adds colours channel-wise and clamps at 255. Additive blending is how you draw glows, fire, lasers, and light — stack several bright shapes and the overlaps brighten toward white. Set it back to <code>0</code> when done.
```ludic
program Demo {
handler DrawWorld phase Render {
Screen.blend_mode(1)
Screen.fill_circle(x: 150, y: 120, radius: 30, color: Color.rgb(80, 20, 0))
Screen.fill_circle(x: 170, y: 120, radius: 30, color: Color.rgb(80, 20, 0))
Screen.blend_mode(0)
Screen.show()
}
}
```

View file

@ -0,0 +1,28 @@
---
id: screen-camera
name: Screen.camera
category: screen
kind: namespace-method
tokens: Screen.camera
sig: Screen.camera(x, y)
tip: Set the world-space camera offset for the draw path.
order: 18
ns: Screen
member: camera
---
Sets a camera offset applied to every draw: a world point <code>(wx, wy)</code> lands on screen at <code>(wx - x, wy - y)</code>. Move it to scroll the world under a fixed viewport. The camera moves <em>everything</em> drawn, so reset it to <code>(0, 0)</code> before drawing a fixed HUD. Same control as <a href="camera-set"><code>Camera.set</code></a>. Deterministic.
```ludic
program Demo {
property Position { x: int = 0, y: int = 0 }
model Player { Position }
handler DrawWorld phase Render {
Screen.camera(Position.x - 160, Position.y - 120) # centre on the player
Screen.fill_rectangle(x: 0, y: 0, width: 16, height: 16, color: Color.Red)
Screen.camera(0, 0) # HUD in screen space
Screen.draw_text(x: 4, y: 4, text: "SCORE", color: Color.White, scale: 1)
Screen.show()
}
}
```

View file

@ -0,0 +1,25 @@
---
id: screen-clip
name: Screen.clip
category: screen
kind: namespace-method
tokens: Screen.clip
sig: Screen.clip(x, y, width, height)
tip: Restrict drawing to a screen-space rectangle.
order: 19
ns: Screen
member: clip
---
Restricts all subsequent drawing to the screen-space rectangle <code>(x, y, width, height)</code> — pixels outside it are discarded. Use it to keep a panel's contents inside its frame, mask a minimap, or draw a wipe transition. Call <a href="screen-clip_reset"><code>Screen.clip_reset</code></a> to lift it. The clip rectangle is in screen space, applied after the camera offset.
```ludic
program Demo {
handler DrawWorld phase Render {
Screen.clip(x: 20, y: 20, width: 100, height: 60)
Screen.fill_rectangle(x: 0, y: 0, width: 320, height: 240, color: Color.Green)
Screen.clip_reset()
Screen.show()
}
}
```

View file

@ -0,0 +1,25 @@
---
id: screen-clip_reset
name: Screen.clip_reset
category: screen
kind: namespace-method
tokens: Screen.clip_reset
sig: Screen.clip_reset()
tip: Lift the clip rectangle (draw to the whole screen again).
order: 20
ns: Screen
member: clip_reset
---
Clears any clip rectangle set by <a href="screen-clip"><code>Screen.clip</code></a>, restoring drawing to the whole framebuffer. Call it once you are done drawing inside a masked region.
```ludic
program Demo {
handler DrawWorld phase Render {
Screen.clip(x: 0, y: 0, width: 64, height: 64)
Screen.circle(x: 32, y: 32, radius: 40, color: Color.Yellow)
Screen.clip_reset()
Screen.show()
}
}
```

View file

@ -0,0 +1,25 @@
---
id: screen-measure_text
name: Screen.measure_text
category: screen
kind: namespace-method
tokens: Screen.measure_text
sig: Screen.measure_text(text) -> int
tip: The pixel advance width of text in the built-in font.
order: 22
ns: Screen
member: measure_text
---
Returns the width in pixels that <code>text</code> occupies in the built-in 5x7 font at scale 1 — 6 pixels per glyph, matching how <a href="screen-draw_text"><code>Screen.draw_text</code></a> advances. Use it to right-align or centre text, size a label's background, or lay out a menu.
```ludic
program Demo {
handler DrawWorld phase Render {
let label = "READY"
let w = Screen.measure_text(label)
Screen.draw_text(x: 160 - w / 2, y: 100, text: label, color: Color.White, scale: 1)
Screen.show()
}
}
```

View file

@ -0,0 +1,24 @@
---
id: screen-oval
name: Screen.oval
category: screen
kind: namespace-method
tokens: Screen.oval
sig: Screen.oval(x, y, rx, ry, color)
tip: Draw an axis-aligned ellipse outline.
order: 17
ns: Screen
member: oval
---
Draws the outline of an ellipse centred at <code>(x, y)</code> with horizontal radius <code>rx</code> and vertical radius <code>ry</code>, in <code>color</code>, by the integer midpoint algorithm. Equal radii draw a circle; use it for eyes, planets, health auras, and selection ovals.
```ludic
program Demo {
handler DrawWorld phase Render {
Screen.clear(Color.Black)
Screen.oval(x: 160, y: 120, rx: 60, ry: 30, color: Color.Cyan)
Screen.show()
}
}
```

View file

@ -0,0 +1,24 @@
---
id: screen-pixel
name: Screen.pixel
category: screen
kind: namespace-method
tokens: Screen.pixel
sig: Screen.pixel(x, y) -> int
tip: Read a framebuffer pixel (0x00RRGGBB), or 0 if off-screen.
order: 23
ns: Screen
member: pixel
---
Reads the colour of the framebuffer pixel at screen <code>(x, y)</code> as <code>0x00RRGGBB</code>, or <code>0</code> if the coordinate is off-screen. Unlike drawing, it ignores the camera — it reports the actual screen. Handy for colour-based collision or hit-testing against what was rendered, and for pixel-exact tests.
```ludic
program Demo {
handler DrawWorld phase Render {
Screen.put_pixel(x: 10, y: 10, color: Color.Red)
if Screen.pixel(10, 10) == Color.Red { Screen.status("hit") }
Screen.show()
}
}
```

View file

@ -0,0 +1,79 @@
# render.ludic — the rendering & camera extras: Screen.pixel/oval/measure_text,
# Screen.camera/clip/blend_mode, and Camera.set/follow/shake. Each assertion that
# holds prints its number, so a full run prints:
# 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
# It draws into the headless framebuffer and reads pixels back with Screen.pixel,
# so it verifies the actual rendered result (not just that a call compiled). All
# deterministic. Colours are 0x00RRGGBB.
program Render {
property Tag { v: int = 0 }
model Marker { Tag }
handler Boot phase Start {
let WHITE = 16777215 # 0xFFFFFF
let RED = 16711680 # 0xFF0000
# --- Screen.pixel round-trips a plotted pixel ---
Screen.clear(0)
Screen.put_pixel(10, 20, WHITE)
if Screen.pixel(10, 20) == WHITE { print(1) }
if Screen.pixel(11, 20) == 0 { print(2) }
# --- Screen.camera offsets the draw path (world -> screen) ---
Screen.camera(5, 5)
Screen.put_pixel(30, 30, RED) # lands at screen (25, 25)
if Screen.pixel(25, 25) == RED { print(3) }
if Screen.pixel(30, 30) == 0 { print(4) }
Screen.camera(0, 0)
# --- Camera.set is the same offset control ---
Camera.set(8, 8)
Screen.put_pixel(40, 40, 65280) # green, lands at (32, 32)
if Screen.pixel(32, 32) == 65280 { print(5) }
Camera.set(0, 0)
# --- Camera.follow eases the offset toward centring a target ---
Camera.follow(260, 120, fixed(1)) # tx = 260 - 160 = 100 -> cam_x = 100
Screen.put_pixel(100, 50, 255) # blue, lands at screen (0, 50)
if Screen.pixel(0, 50) == 255 { print(6) }
Camera.set(0, 0)
# --- Camera.shake(0) clears any shake (draw lands unshifted) ---
Camera.shake(0)
Screen.put_pixel(70, 70, WHITE)
if Screen.pixel(70, 70) == WHITE { print(7) }
# --- Screen.clip restricts drawing to a rectangle ---
Screen.clip(0, 0, 4, 4)
Screen.put_pixel(2, 2, WHITE) # inside the clip
Screen.put_pixel(12, 12, WHITE) # outside the clip
if Screen.pixel(2, 2) == WHITE { print(8) }
if Screen.pixel(12, 12) == 0 { print(9) }
Screen.clip_reset()
Screen.put_pixel(12, 12, WHITE) # now allowed
if Screen.pixel(12, 12) == WHITE { print(10) }
# --- Screen.blend_mode(1) adds colours channel-wise, clamped at 255 ---
Screen.blend_mode(1)
Screen.put_pixel(90, 90, Color.rgb(100, 0, 0))
Screen.put_pixel(90, 90, Color.rgb(80, 0, 0))
if Screen.pixel(90, 90) == Color.rgb(180, 0, 0) { print(11) }
Screen.put_pixel(90, 90, Color.rgb(200, 0, 0)) # 180 + 200 -> clamp 255
if Screen.pixel(90, 90) == Color.rgb(255, 0, 0) { print(12) }
Screen.blend_mode(0)
# --- Screen.oval outlines an ellipse (rx != ry) ---
Screen.clear(0)
Screen.oval(50, 50, 10, 6, WHITE)
if Screen.pixel(60, 50) == WHITE { print(13) } # rightmost
if Screen.pixel(40, 50) == WHITE { print(14) } # leftmost
if Screen.pixel(50, 56) == WHITE { print(15) } # bottom
if Screen.pixel(50, 50) == 0 { print(16) } # outline, not filled
# --- Screen.measure_text: advance width in the 5x7 font (6 px/glyph) ---
if Screen.measure_text("abc") == 18 { print(17) }
if Screen.measure_text("") == 0 { print(18) }
}
handler Run phase Update { quit() }
}

View file

@ -27,6 +27,23 @@ 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 {
@ -39,6 +56,8 @@ function rt_init() -> void {
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()
@ -69,25 +88,46 @@ function rt_clear(c: int) -> void {
}
}
# 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 {
if x < 0 { return }
if y < 0 { return }
if x >= rt_fbw { return }
if y >= rt_fbh { return }
rt_fb[y * rt_fbw + x] = c
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 x0 = max(0, x)
let y0 = max(0, y)
let x1 = min(rt_fbw, x + w)
let y1 = min(rt_fbh, y + h)
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 {
rt_fb[row + i] = c
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
@ -172,6 +212,108 @@ function rt_fill_triangle(x0: int, y0: int, x1: int, y1: int, x2: int, y2: int,
}
}
# 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 {

View file

@ -127,6 +127,21 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "fill_triangle") { bare = "fill_triangle"; push(labels, "x1"); push(labels, "y1"); push(labels, "x2"); push(labels, "y2"); push(labels, "x3"); push(labels, "y3"); push(labels, "color") }
if (meth == "sprite") { bare = "draw_sprite"; push(labels, "id"); push(labels, "x"); push(labels, "y") }
if (meth == "sprite_scaled") { bare = "draw_sprite_scaled"; push(labels, "id"); push(labels, "x"); push(labels, "y"); push(labels, "scale") }
if (meth == "oval") { bare = "oval"; push(labels, "x"); push(labels, "y"); push(labels, "rx"); push(labels, "ry"); push(labels, "color") }
if (meth == "camera") { bare = "camera"; push(labels, "x"); push(labels, "y") }
if (meth == "clip") { bare = "clip"; push(labels, "x"); push(labels, "y"); push(labels, "width"); push(labels, "height") }
if (meth == "clip_reset") { bare = "clip_reset" }
if (meth == "blend_mode") { bare = "blend_mode"; push(labels, "mode") }
if (meth == "measure_text") { bare = "measure_text"; push(labels, "text") }
if (meth == "pixel") { bare = "get_px"; push(labels, "x"); push(labels, "y") }
}
# Camera.* — the world-space camera: a draw offset threaded through the render
# path (runtime/native/core.ludic). set/follow move it; shake jitters it from
# the seeded RNG, so a replay shakes identically.
if (ns == "Camera") {
if (meth == "set") { bare = "camera"; push(labels, "x"); push(labels, "y") }
if (meth == "follow") { bare = "camera_follow"; push(labels, "x"); push(labels, "y"); push(labels, "lerp") }
if (meth == "shake") { bare = "camera_shake"; push(labels, "amount") }
}
if (ns == "Map") {
if (meth == "size") { bare = "map_size"; push(labels, "width"); push(labels, "height") }

File diff suppressed because it is too large Load diff

View file

@ -107,6 +107,7 @@ function cmd_test() -> int {
feat_case("library/anim", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34", "anim.ludic (Anim frame/once/pingpong/cell + Tween progress/loop/yoyo/ease/number/round/point/tint)")
feat_case("library/query", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "query.ludic (Query count/first/nearest/within — ECS spatial queries over the reflection ABI)")
feat_case("library/reflect", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "reflect.ludic (Reflect prop/field enumeration + type + get/set/has/kind — runtime reflection over the world schema)")
feat_case("library/render", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "render.ludic (Screen pixel/oval/camera/clip/blend_mode/measure_text + Camera set/follow/shake, verified by pixel readback)")
feat_case("library/logging", "", "0 5 2 1", "logging.ludic (Log levels, set_level/level threshold, structured fields)")
# Os known-folders/arch and Fs.list read the BSD utsname/dirent layout, so
# their asserted values are macOS-specific; skip off Darwin (see is_darwin).