diff --git a/core.cm b/core.cm index db68e3c..b4b9b47 100644 --- a/core.cm +++ b/core.cm @@ -775,7 +775,6 @@ return { clear: clear, // Internal (for runner) - _state: _state, _begin_frame: _begin_frame, _process_events: _process_events, _end_frame: _end_frame diff --git a/examples/cube.ce b/examples/cube.ce index 6789ab4..0e2f8ec 100644 --- a/examples/cube.ce +++ b/examples/cube.ce @@ -96,7 +96,7 @@ function _update(dt) { // Exit on escape if (lance3d._state.keys_held['escape']) { - $_.stop() + $stop() } } @@ -129,7 +129,7 @@ function frame() { // Process events if (!lance3d._process_events()) { log.console("Exiting...") - $_.stop() + $stop() return } @@ -148,7 +148,7 @@ function frame() { lance3d._end_frame() // Schedule next frame - $_.delay(frame, 1/60) + $delay(frame, 1/60) } // Start diff --git a/examples/forest.ce b/examples/forest.ce index d830ff2..ad010b3 100644 --- a/examples/forest.ce +++ b/examples/forest.ce @@ -91,7 +91,7 @@ function _init() { function _update(dt) { if (lance3d.key('escape')) { - $_.stop() + $stop() return } @@ -173,7 +173,7 @@ function frame() { if (!lance3d._process_events()) { log.console("Exiting...") - $_.stop() + $stop() return } @@ -186,7 +186,7 @@ function frame() { lance3d._end_frame() - $_.delay(frame, 1/60) + $delay(frame, 1/60) } _init() diff --git a/examples/modelview.ce b/examples/modelview.ce index d4a054e..36e64d6 100644 --- a/examples/modelview.ce +++ b/examples/modelview.ce @@ -48,7 +48,7 @@ function _init() { model = lance3d.load_model(model_path) if (!model) { log.console("Error: Could not load model: " + model_path) - $_.stop() + $stop() return } @@ -123,7 +123,7 @@ function _update(dt) { // Exit on escape if (lance3d.key('escape')) { - $_.stop() + $stop() } // Toggle animation with space @@ -213,7 +213,7 @@ function frame() { // Process events if (!lance3d._process_events()) { log.console("Exiting...") - $_.stop() + $stop() return } @@ -232,7 +232,7 @@ function frame() { lance3d._end_frame() // Schedule next frame - $_.delay(frame, 1/240) + $delay(frame, 1/240) } // Start diff --git a/resources.cm b/resources.cm index 85fa2ef..5e67f58 100644 --- a/resources.cm +++ b/resources.cm @@ -11,7 +11,7 @@ var skin_mod = use('skin') var _backend = null // Texture original data storage for re-resizing on style change -var TEX_ORIGINAL = key("texture_original") +var TEX_ORIGINAL = "resources:texture_original" // Default material prototype var _default_material = { diff --git a/website/manual.md b/website/manual.md index 18e08f4..495a1cb 100644 --- a/website/manual.md +++ b/website/manual.md @@ -1,122 +1,566 @@ -# Lance 3D +☾ -# Core API -Set style ps1 / n64 / saturn +# Lance 3D Manual (v1) -set_style("ps1" | "n64" | "saturn") +Lance 3D is a focused 3D fantasy-console engine for making games with a PS1 / N64 / Saturn kind of visual personality. It’s immediate-mode: you load some assets, set a camera, draw every frame, ship. -Get time since game boot, in seconds **time()** - -Get runtime statistics - -stat(name: "draw_calls" | "triangles" | "fps" | "memory_bytes") +Game code is written in **CellScript** (not JavaScript): +```text +https://cell-lang.org/cellscript/ ``` -set_lighting({ - sun_dir: [0.3,-1,0.2], // normalized internally - sun_color: [1,1,1], // rgb - ambient: [0.25,0.25,0.25] // rgb + +Networking is **v2**. + +--- + +## Specifications + +* **Target FPS:** 60 +* **VSync:** always on +* **Internal render size:** 320×240 (always) +* **Windowing:** letterboxed into whatever window size you choose +* **Screen coordinates:** origin at **bottom-left** + + * Pixel addressable range: **x: 0..319**, **y: 0..239** + * Floats are allowed (subpixel placement), but the result is still “pixel-ish” +* **3D coordinates:** right-handed, **Z-up** +* **Units:** 1 unit = 1 meter +* **Angles:** radians (`pi` exists) + +Assets: + +* Models: `.glb` +* Textures: `.png` / `.jpg` +* Sounds: `.wav` / `.mp3` (converted to 16-bit PCM @ 44.1 kHz) +* Music: `.mid` / `.midi` via the embedded chip + +Voices (maximum simultaneously): + +* PS1: **24** +* N64: **24** +* Saturn: **32** + +Voice cap includes *everything*: `play_sound()` voices + voices produced by the MIDI/chip. + +Hot reload: + +* The engine watches and reloads changed **models** and **textures** during development. + +--- + +## The big rule: immediate mode + +Lance 3D does not keep a “scene.” It draws what you ask it to draw **this frame**. + +If you want: + +* a scene tree → you build it +* a global “current material” → you build it +* an entity system → you build it + +Lance 3D mostly retains **assets**, not **render intent**. + +### What resets each frame + +At the start of each `draw()` call, Lance 3D resets: + +* camera +* lighting +* fog +* 2D clip/scissor + +So if you want those, you set them in `draw()`. + +--- + +## Getting started + +A Lance 3D “game” is a folder. Asset paths are **absolute-from-game-root**, and you **omit extensions**. + +* `"man/model"` loads `"man/model.glb"` +* `"tex/brick"` loads `"tex/brick.png"` or `"tex/brick.jpg"` +* `"sfx/hit"` loads `"sfx/hit.wav"` or `"sfx/hit.mp3"` + +Top-level code runs once at startup: load assets, create meshes, initialize state. + +### The main loop + +You don’t write the loop. You register callbacks. + +```cell +var t = 0 + +register({ + update(dt) { t += dt }, + draw() { /* draw every frame */ } }) ``` -set_fog({ - enabled: false, - color: [0.5,0.6,0.7], - near: 10, - far: 80 -}) +* `update(dt)` once per frame, `dt` in seconds +* `draw()` once per frame -// lance3d material -{ - color_map: texture | null, // optional - paint: color, // required (default: white) - coverage: "opaque" | "cutoff" | "blend", - face: "single" | "double", - lamp: "lit" | "unlit" +--- + +## Hello cube + +```cell +def cube = make_cube(1, 1, 1) +def tex = load_texture("tex/brick") + +def mat = { + color_map: tex, + paint: [1, 1, 1, 1], + coverage: "opaque", + face: "single", + lamp: "lit" } -## Draw API -Loads a gltf model, returning an array of {mesh, material}, with materials compressed into a render3d material -load_model(path) -> array<{mesh, material}> +var a = 0 -make_cube(w, h, d) -> mesh -make_sphere(r, segments=12) -> mesh -make_cylinder(r, h, segments=12) -> mesh -make_plane(w, h) -> mesh +register({ + update(dt) { a += dt }, -Gets information about the animations on a model -anim_info(model) + draw() { + set_style("ps1") -Generates a pose that can be applied to a model -sample_pose(model, name, time) + camera_perspective(pi / 3, 0.1, 100) + camera_look_at(3, 3, 2, 0, 0, 0) -Draws a model with a given base transform -draw_model(model, transform=null, pose=null) + set_lighting({ + sun_dir: [0.3, -1, 0.2], + sun_color: [1, 1, 1], + ambient: [0.25, 0.25, 0.25] + }) -Draws a mesh with a material -draw_mesh(mesh, transform=null, material=null) + def trs = trs_matrix(0, 0, 0, 0, 0, a, 1, 1, 1) + draw_mesh(cube, trs, mat) + } +}) +``` -load_sound(path) -> sound | null +If you see loud purple, you drew without a material. That’s intentional. -play_sound(sound, opts={ - volume: 1.0, // 0..1 - pitch: 1.0, // 1.0 = normal - pan: 0.0, // -1 = left, 0 = center, 1 = right - loop: false // boolean -}) -> voice +--- -stop_sound(voice) +## Time, logging, stats -// when drawing, a provided material will override defaults for the system -load_texture(path) -> texture -draw_billboard(texture, x, y, z, size=1.0, mat=null) -draw_sprite(texture, x, y, size=1.0, mat=null) +> **`time()` → number** +> Seconds since boot. -## Camera API -camera_look_at( - ex, ey, ez, // eye - tx, ty, tz, // target - upx = 0, upy = 1, upz = 0 -) +> **`log(...args)` → void** +> Print to the log. -camera_perspective(fov_deg=60, near=0.1, far=1000) -camera_ortho(left, right, bottom, top, near=-1, far=1) +> **`stat(name)` → number** +> `name`: `"draw_calls" | "triangles" | "fps" | "memory_bytes"` +> Throws if unknown. -## Collision API -add_collider_sphere(transform, radius, opts={layer_mask:1, user:null}) -> collider -add_collider_box(transform, sx, sy, sz, opts={layer_mask:1, user:null}) -> collider +--- -overlaps(layer_mask_a=null, layer_mask_b=null) -> array<{a: collider, b: collider}> +## Style (PS1 / N64 / Saturn) -raycast(ox,oy,oz, dx,dy,dz, opts={ - max_dist: Infinity, - layer_mask: 0xFFFFFFFF -}) -> null | {x,y,z, nx,ny,nz, distance, collider} +Style is global and persists until changed. -retro3d.remove_collider(collider) +> **`set_style(style = "ps1")` → void** +> `style`: `"ps1" | "n64" | "saturn"` + +Triangle limits are soft: warnings, not failures. + +### Texture & model tiers (low / normal / hero) + +Textures and models are treated as belonging to a tier and interpreted through the current style. + +| Style | Low | Normal | Hero | +| ------ | --- | ------ | ---- | +| PS1 | 64 | 128 | 256 | +| N64 | — | 32 | 64 | +| Saturn | 32 | 64 | 128 | + +Defaults: + +* **Textures:** `normal` +* **Models:** `normal` + +You can tag assets: + +> **`tag_texture(texture, tier = "normal")` → void** +> **`tag_model(model, tier = "normal")` → void** +> `tier`: `"low" | "normal" | "hero"` + +Tags are hints to the style system (budget + interpretation). They don’t invent LODs for you. + +--- + +## Environment (camera, lighting, fog) + +These reset each `draw()`. Set them in `draw()`. + +### Camera + +> **`camera_look_at(ex, ey, ez, tx, ty, tz, upx = 0, upy = 0, upz = 1)` → void** + +> **`camera_perspective(fov = pi/3, near = 0.1, far = 1000)` → void** +> FOV is radians. Throws if planes are invalid. + +> **`camera_ortho(left, right, bottom, top, near = -1, far = 1)` → void** + +### Lighting + +> **`set_lighting({ sun_dir, sun_color, ambient })` → void** +> All colors are 0..1. `sun_dir` is normalized internally. + +### Fog + +> **`set_fog({ enabled=false, color, near, far })` → void** +> Fog is a first-class style tool. + +--- + +## Transforms + +Transforms are opaque TRS blobs created by `trs_matrix(...)`. Functions accept a transform blob or `null` for identity. + +> **`trs_matrix(tx, ty, tz, rx, ry, rz, sx = 1, sy = 1, sz = 1)` → transform** + +* translation in meters +* rotation is Euler **radians**, applied in **XYZ order** +* scale is unitless multiplier + +(If you want quaternions, build your own helper and still feed the resulting matrix/blob shape the engine expects.) + +--- + +## Materials + +Materials are plain objects with a fixed shape. No shaders. + +```cell +def brick = { + color_map: load_texture("tex/brick"), + paint: [1, 1, 1, 1], + coverage: "opaque", + face: "single", + lamp: "lit" +} +``` + +Fields: + +* `color_map: texture | null` +* `paint: [r,g,b,a]` (0..1) +* `coverage: "opaque" | "cutoff" | "blend"` + + * `"cutoff"` uses alpha threshold **0.5** +* `face: "single" | "double"` + + * Front faces are **clockwise (CW)** +* `lamp: "lit" | "unlit"` + +Transparency: + +* `"blend"` draws are depth-sorted back-to-front (simple, stable, not perfect). + +--- + +## Assets and paths + +Loads are synchronous. Missing assets return `null` and log details. + +> **`load_texture(path)` → texture | null** +> **`load_model(path)` → model | null** +> **`load_sound(path)` → sound | null** +> **`load_font(path)` → font | null** + +Asset handles are stable by path: + +* calling `load_texture("tex/brick")` again returns the same handle +* hot reload updates the content behind that handle in development + +--- + +## Drawing order + +1. 3D world (meshes, models, billboards) +2. 2D overlay (sprites, text, 2D primitives) + +**2D always draws over 3D.** +To get “sprites in 3D,” use **billboards**. + +--- + +## Drawing 3D + +> **`draw_model(model, transform = null, pose = null)` → void** +> Uses embedded model materials. + +> **`draw_mesh(mesh, transform = null, material = null)` → void** +> If `material=null`, uses the missing-material purple. + +--- + +## Billboards (world space) + +Billboards face the camera and are anchored at **bottom-center**. + +> **`draw_billboard(texture, x, y, z, size = 1.0, material = null)` → void** + +`size` is in **meters** (height). Aspect ratio comes from the texture. + +--- + +## 2D drawing (PICO-8 style) + +Everything here is screen space (320×240), anchored at bottom-left, drawn over 3D. + +### Sprites + +> **`draw_sprite(texture, x, y, size = 1.0, material = null)` → void** + +`size` is a pixel scale: + +* `size=1.0` draws at the texture’s authored pixel size in 320×240 space +* `size=2.0` doubles it, etc. + +### Text + +Lance 3D ships with a built-in default font. You can also pass a font per draw. + +> **`draw_text(text, x, y, scale = 1.0, color = [1,1,1,1], font = null)` → void** + +* `\n` creates a new line +* unknown glyphs render as `?` +* `font=null` uses the built-in font + +### Simple primitives + +> **`pset(x, y, color = [1,1,1,1])` → void** +> **`line2(x0, y0, x1, y1, color = [1,1,1,1])` → void** +> **`rect(x, y, w, h, color = [1,1,1,1])` → void** +> **`rectfill(x, y, w, h, color = [1,1,1,1])` → void** +> **`circ(x, y, r, color = [1,1,1,1])` → void** +> **`circfill(x, y, r, color = [1,1,1,1])` → void** +> **`clip(x, y, w, h)` → void** (enable) +> **`clip()` → void** (disable; also resets each `draw()`) + +--- + +## Procedural geometry + +> **`make_cube(w = 1, h = 1, d = 1)` → mesh** +> **`make_sphere(r = 1, segments = 12)` → mesh** +> **`make_cylinder(r = 1, h = 1, segments = 12)` → mesh** +> **`make_plane(w = 1, h = 1)` → mesh** + +--- + +## Animation + +> **`anim_info(model)` → any** +> Structured info about available animations. + +> **`sample_pose(model, name, t)` → pose | null** +> `t` is seconds. Nothing advances unless you advance it. + +--- + +## Audio + +Stereo output. Converted to 16-bit PCM @ 44.1kHz. + +> **`play_sound(sound, { volume=1, pitch=1, pan=0, loop=false })` → voice** +> **`stop_sound(voice)` → void** + +Embedded chip / MIDI: + +> **`chip(channel, op, value)` → void** +> Examples: + +* `chip(0, "play_midi", "music/theme")` +* `chip(0, "volume", 120)` +* `chip(2, "mute", 1)` + +Voice stealing (when cap is hit) is deterministic: + +* lowest priority loses first (chip voices are lowest by default) +* ties: oldest voice loses + +--- + +## Collision (v1: simple queries) + +Collision in v1 is **stateless queries**. No collider world, no layers, no retained physics state. + +### Types + +Use plain objects: + +```cell +def ray = { origin: [ox, oy, oz], dir: [dx, dy, dz] } // dir should be normalized +def box = { min: [minx, miny, minz], max: [maxx, maxy, maxz] } // AABB +``` + +Ray hits return `hit | null`: + +```cell +// when hit: +{ dist: number, point: [x,y,z], normal: [nx,ny,nz] } + +// when miss: +null +``` + +### Shape checks + +> **`check_collision_spheres(c1, r1, c2, r2)` → bool** +> **`check_collision_boxes(box1, box2)` → bool** +> **`check_collision_box_sphere(box, center, radius)` → bool** + +### Ray queries + +> **`get_ray_collision_sphere(ray, center, radius)` → hit | null** +> **`get_ray_collision_box(ray, box)` → hit | null** +> **`get_ray_collision_mesh(ray, mesh, transform = null)` → hit | null** + +### Mesh collider (cached acceleration) + +For repeated raycasts (picking, bullets, LOS checks), you can precompute a mesh collider once: + +> **`make_mesh_collider(mesh)` → mesh_collider** + +This builds an internal acceleration structure (BVH) in **mesh local space**. + +Then use: + +> **`get_ray_collision_mesh_collider(ray, mesh_collider, transform = null)` → hit | null** + +Same result as `get_ray_collision_mesh`, but faster for repeated queries. + +### Will this work? + +Yes—this is a solid “fantasy-console collision” model **if you keep the expectations correct**: + +* Great for: triggers, overlap tests, ray picking, hitscan weapons, simple AI line-of-sight. +* Not a physics engine: no solver, no continuous collision handling, no automatic contact resolution. + +Mesh colliders specifically: + +* Great when the mesh is **static** (level geometry) or the mesh itself doesn’t change. +* Fine for moving instances: pass a different `transform` per query. +* If the mesh deforms/changes topology, you must rebuild the collider (call `make_mesh_collider` again). + +--- ## Input -btn(id, player=0) -> bool // held -btnp(id, player=0) -> bool // pressed this frame -## Math -seed(seed_int) -rand() -> number // [0,1) -irand(min_inclusive, max_inclusive) -> int +> **`btn(id, player = 0)` → bool** +> **`btnp(id, player = 0)` → bool** -## Debug API -vertex {x, y, z, u=0, v=0, color} -point(vertex, size=1.0) -line(vertex_a, vertex_b, width=1.0) -grid(size, step, norm = {x:0,y:1,z:0}, color) +Button IDs: -triangle(vertex_a, vertex_b, vertex_c, mat=null) -ray(ox,oy,oz, dx,dy,dz, len, opts) +* 0 left, 1 right, 2 down, 3 up +* 4 A, 5 B, 6 X, 7 Y +* 8 L, 9 R +* 10 Start, 11 Select -aabb(cx,cy,cz, ex,ey,ez, opts) // center+extents -obb(transform, sx,sy,sz, opts) // oriented via transform -frustum(camera_state_or_params, opts) -text3d(str, x,y,z, opts={size_px:12, depth:"always"|"test"}) +--- -Render a specific collider -collider(collider, color) +## Debug drawing (world space) + +> **`dbg_line(a, b, color = [1,0,0])` → void** +> **`dbg_aabb(box, color = [1,0,0])` → void** +> **`dbg_ray(ray, color = [1,0,0])` → void** + +--- + +# Cheat sheet (v1 API) + +## Core + +* `register({ update(dt), draw() })` // start the game loop +* `time()` // seconds since boot +* `log(...args)` // print to log +* `stat(name)` // runtime stats: draw_calls/triangles/fps/memory_bytes + +## Style + +* `set_style(style = "ps1")` // set visual style +* `tag_texture(texture, tier = "normal")` // tag texture as low/normal/hero +* `tag_model(model, tier = "normal")` // tag model as low/normal/hero + +## Environment + +* `camera_look_at(ex, ey, ez, tx, ty, tz, upx=0, upy=0, upz=1)` // set view +* `camera_perspective(fov=pi/3, near=0.1, far=1000)` // set perspective projection +* `camera_ortho(left, right, bottom, top, near=-1, far=1)` // set ortho projection +* `set_lighting({ sun_dir, sun_color, ambient })` // set sun + ambient +* `set_fog({ enabled=false, color, near, far })` // set fog + +## Transforms + +* `trs_matrix(tx, ty, tz, rx, ry, rz, sx=1, sy=1, sz=1)` // build transform blob (TRS) + +## Assets + +* `load_texture(path)` // load png/jpg, returns texture or null +* `load_model(path)` // load glb, returns model or null +* `load_sound(path)` // load wav/mp3, returns sound or null +* `load_font(path)` // load a font, returns font or null + +## Procedural geometry + +* `make_cube(w=1, h=1, d=1)` // cube mesh +* `make_sphere(r=1, segments=12)` // sphere mesh +* `make_cylinder(r=1, h=1, segments=12)` // cylinder mesh +* `make_plane(w=1, h=1)` // plane mesh + +## Drawing 3D + +* `draw_model(model, transform=null, pose=null)` // draw model with embedded materials +* `draw_mesh(mesh, transform=null, material=null)` // draw mesh with optional material override +* `draw_billboard(texture, x, y, z, size=1.0, material=null)` // camera-facing sprite in world + +## Drawing 2D + +* `draw_sprite(texture, x, y, size=1.0, material=null)` // screen-space sprite over 3D +* `draw_text(text, x, y, scale=1.0, color=[1,1,1,1], font=null)` // screen-space text +* `pset(x, y, color=[1,1,1,1])` // set pixel +* `line2(x0, y0, x1, y1, color=[1,1,1,1])` // draw line +* `rect(x, y, w, h, color=[1,1,1,1])` // rectangle outline +* `rectfill(x, y, w, h, color=[1,1,1,1])` // filled rectangle +* `circ(x, y, r, color=[1,1,1,1])` // circle outline +* `circfill(x, y, r, color=[1,1,1,1])` // filled circle +* `clip(x, y, w, h)` // enable 2D clipping +* `clip()` // disable 2D clipping + +## Animation + +* `anim_info(model)` // inspect available animations +* `sample_pose(model, name, t)` // sample pose at time t, returns pose or null + +## Audio + +* `play_sound(sound, { volume=1, pitch=1, pan=0, loop=false })` // play a sound voice +* `stop_sound(voice)` // stop a playing voice +* `chip(channel, op, value)` // control embedded chip / MIDI + +## Collision + +* `check_collision_spheres(c1, r1, c2, r2)` // sphere-sphere overlap +* `check_collision_boxes(box1, box2)` // AABB-AABB overlap +* `check_collision_box_sphere(box, center, radius)` // AABB-sphere overlap +* `get_ray_collision_sphere(ray, center, radius)` // ray vs sphere, returns hit or null +* `get_ray_collision_box(ray, box)` // ray vs AABB, returns hit or null +* `get_ray_collision_mesh(ray, mesh, transform=null)` // ray vs mesh, returns hit or null +* `make_mesh_collider(mesh)` // build cached collider (BVH) from mesh +* `get_ray_collision_mesh_collider(ray, mesh_collider, transform=null)` // fast ray vs mesh collider + +## Input + +* `btn(id, player=0)` // button held +* `btnp(id, player=0)` // button pressed this frame + +## Debug + +* `dbg_line(a, b, color=[1,0,0])` // debug line +* `dbg_aabb(box, color=[1,0,0])` // debug AABB +* `dbg_ray(ray, color=[1,0,0])` // debug ray