feat(rendering): add Screen.camera/clip/blend_mode/oval + Camera.* + Screen.pixel (#23)
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:
parent
12f2dbe958
commit
31cfbc2465
16 changed files with 8383 additions and 7183 deletions
7
docs/language/camera/_section.md
Normal file
7
docs/language/camera/_section.md
Normal 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.
|
||||
29
docs/language/camera/camera-follow.md
Normal file
29
docs/language/camera/camera-follow.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
25
docs/language/camera/camera-set.md
Normal file
25
docs/language/camera/camera-set.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
29
docs/language/camera/camera-shake.md
Normal file
29
docs/language/camera/camera-shake.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
26
docs/language/screen/screen-blend_mode.md
Normal file
26
docs/language/screen/screen-blend_mode.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
28
docs/language/screen/screen-camera.md
Normal file
28
docs/language/screen/screen-camera.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
25
docs/language/screen/screen-clip.md
Normal file
25
docs/language/screen/screen-clip.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
25
docs/language/screen/screen-clip_reset.md
Normal file
25
docs/language/screen/screen-clip_reset.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
25
docs/language/screen/screen-measure_text.md
Normal file
25
docs/language/screen/screen-measure_text.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
24
docs/language/screen/screen-oval.md
Normal file
24
docs/language/screen/screen-oval.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
24
docs/language/screen/screen-pixel.md
Normal file
24
docs/language/screen/screen-pixel.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue