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()
}
}
```