From 9619d0f2675dde4d7507516f75538edae4cd34ca Mon Sep 17 00:00:00 2001 From: grabowski Date: Tue, 27 Oct 2026 16:39:43 +0700 Subject: [PATCH] Wildlife: hamster and hamster wheel prefabs Ported from the thaiai.lol easter egg (thaiai iso/hamster/lib/hamster.rhai); the data-centre joke parts stay there. - hamster (Animals): golden, grey or cream with a white belly, pink nose, ears and paws, buck teeth and whiskers; standing, running on the spot or asleep (eyes shut, z Z); n and spread like the herds; life size (about 17 cm) at scale 1, up to 40 times. - hamster_wheel (Animals): diameter (0.3 m life size, up to 12 m), running, A-frame stand or a cage-wall axle stub, fur colour. Running legs and the rungs are flipbooks of 4 poses, 0.1 s each; the spokes turn by a CSS rotation in a matrix that maps the wheel's plane onto the screen. - draw_parts learns loft2 (two-tone lofts), eye and flip parts; place_parts scales and moves any part list; loft and loft2 share loft_rings (the other animals draw byte for byte as before). - Things under 2 m are drawn ten times bigger in a group that scales them back (fine_begin / fine_end), so a life-size hamster keeps its shape on the drawing's 0.1-unit grid; sort boxes stay real size. - The legs push back on the ground and swing forward lifted (the original lifted them on the way back). - Theme: hamster, hamsterg, hamsterc, hamsterw, hamsterp, hwheel, hwhub, whisker and hamz in both palettes, materials, rules for eyes, whiskers, rims, spokes, hub, rungs and the z Z. Viewer CSS: iso-flip4, iso-wheelspin, iso-runbob, iso-zzz; beast edges take --bw (default .3). - Test hamsters_run_in_wheels_from_every_side: no sort cycles in any rotation, flipbook counts, scaled groups, real-size sort boxes, tooltips. - README, ROADMAP, SCRIPTING and the MCP instructions list them. --- README.md | 20 +- crates/isokit-project/src/mcp.rs | 3 +- crates/isokit-script/src/tests.rs | 91 +++++ crates/isokit-script/stdlib/wildlife.rhai | 453 +++++++++++++++++++++- crates/isokit-theme/themes/default.toml | 33 ++ docs/ROADMAP.md | 7 + docs/SCRIPTING.md | 19 + viewer/isokit-view.css | 12 +- 8 files changed, 609 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 9d331d1..6bf06d0 100644 --- a/README.md +++ b/README.md @@ -83,15 +83,17 @@ swimming round under lily pads, ripples and reeds; trees, hedges and rows of trees are in Nature. **Animals and nature**: Deer (a herd of a stag, does and fawns, or one -kind), Wild boar, Fox, Elephant (bull, cow, calf), Rabbits, a flock of Birds -circling and Butterflies; Beehives on stands with bees flying round, Bushes -(round, flowering, berries), Flowers (a bed in rows or a wildflower meadow), -Rocks and a Log or stump. Animals are low-poly solids that read from every -side; a herd scatters about the object's spot, each animal its own sprite. -Grazing animals lower and raise their heads in turn, elephants curl their -trunks, rabbits hop, birds flap. `examples/country` (Country demo in the web -Studio) puts them in a countryside corner with a lane, fence and hedgerow at -an angle. +kind), Wild boar, Fox, Elephant (bull, cow, calf), Rabbits, Hamsters (golden, +grey or cream; life size, or scaled up for a giant) and a Hamster wheel with +one running in it, a flock of Birds circling and Butterflies; Beehives on +stands with bees flying round, Bushes (round, flowering, berries), Flowers (a +bed in rows or a wildflower meadow), Rocks and a Log or stump. Animals are +low-poly solids that read from every side; a herd scatters about the +object's spot, each animal its own sprite. Grazing animals lower and raise +their heads in turn, elephants curl their trunks, rabbits hop, hamsters run +(legs and the wheel's rungs step through four poses, the spokes turn), birds +flap. `examples/country` (Country demo in the web Studio) puts them in a +countryside corner with a lane, fence and hedgerow at an angle. **Routes**: a Route moves cars, buses, motorbikes, cyclists, people, deer or elephants along waypoints. Pick Route and click point after point on the map diff --git a/crates/isokit-project/src/mcp.rs b/crates/isokit-project/src/mcp.rs index 41173ca..7c79237 100644 --- a/crates/isokit-project/src/mcp.rs +++ b/crates/isokit-project/src/mcp.rs @@ -27,8 +27,9 @@ Fences (props.style: mesh, wood, bars, half stone, cobble, brick) and site walls meet, so for a plot boundary put each piece's ends exactly on the next piece's end or line. Line pieces (roads, walls, \ fences, hedges, house walls) can run at any angle: a piece from (x0, y0) to (x1, y1) has x, y = the midpoint, \ w = the distance and rot = atan2(y1 - y0, x1 - x0) in degrees; where diagonal pieces meet, corners are mitred. \ -Animals (category Animals: deer, boar, fox, elephant, rabbits, birds, butterflies) are herds: props.n animals \ +Animals (category Animals: deer, boar, fox, elephant, rabbits, hamster, birds, butterflies) are herds: props.n animals \ scattered within props.r metres of x, y, facing about the rotation; keep them out of roads, ponds and buildings. \ +Hamsters are life size (props.scale makes giants); a hamster_wheel (props.diameter, 0.3 m) has one running in it. \ A route (type route) moves props.n movers (props.kind: car, bus, motorbike, cyclist, person, deer, elephant) along \ waypoints pts (relative to x, y): round them when props.closed, else there and back; each way keeps to the left \ of the line (props.keep). For traffic, put the points on the road centre lines; keep routes clear of buildings. \ diff --git a/crates/isokit-script/src/tests.rs b/crates/isokit-script/src/tests.rs index 38f327f..c2f9c17 100644 --- a/crates/isokit-script/src/tests.rs +++ b/crates/isokit-script/src/tests.rs @@ -1111,6 +1111,97 @@ fn wildlife_and_garden_prefabs_draw_from_every_side() { assert_eq!(row("hives", "Apiary"), "4 hives, 2 supers each"); } +fn hamster_doc() -> Doc { + let mut turned = obj("tw", "hamster_wheel", 6.0, -4.0, json!({"diameter": 1.2})); + turned.rot = 30.0; + Doc { + objects: vec![ + obj("h1", "hamster", 0.0, 0.0, json!({})), + obj( + "h3", + "hamster", + 3.0, + 0.0, + json!({"n": 3, "colour": "mixed", "pose": "running"}), + ), + obj( + "hz", + "hamster", + 0.0, + 3.0, + json!({"pose": "asleep", "scale": 10}), + ), + obj("w", "hamster_wheel", 3.0, 3.0, json!({})), + obj( + "big", + "hamster_wheel", + -8.0, + -4.0, + json!({"diameter": 8, "running": false, "stand": false, "colour": "grey"}), + ), + turned, + ], + ..Default::default() + } +} + +#[test] +fn hamsters_run_in_wheels_from_every_side() { + let r = Runtime::new(&Bundle::default()).unwrap(); + let doc = hamster_doc(); + for view in 0..4u8 { + let b = r.build("site", view, &cfg(), &doc).unwrap(); + assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings); + let out = render(&b.scene, view, &RenderOpts::default()); + assert!(!out.html.contains("NaN")); + assert_eq!(out.cycles, 0, "view {view}: depth-sort cycles"); + // each hamster its own sprite, each wheel one + assert_eq!(sprites_of(&b, "h3"), 3); + assert_eq!(sprites_of(&b, "w"), 1); + let n = |s: &str| out.html.matches(s).count(); + // flipbooks of 4 poses, the first shown unanimated: the legs of the three runners + // and of the hamsters in the two running wheels, and each running wheel's rungs + // behind and in front of its hamster + let books = 3 * 4 + 2 * 4 + 2 * 2; + assert_eq!(n("iso-a iso-flip4 iso-pose0"), books, "view {view}"); + assert_eq!(n("iso-a iso-flip4\""), 3 * books, "view {view}"); + assert_eq!(n("iso-a iso-wheelspin"), 2); + assert_eq!(n("iso-a iso-runbob"), 3 + 2); + assert_eq!(n("iso-a iso-zzz"), 1); + // two-tone fur in three colours, edged in its own shade; eyes open and shut + for c in ["hamster", "hamsterg", "hamsterc", "hamsterw", "hamsterp"] { + assert!(out.html.contains(&format!("iso-{c} iso-beast")), "{c}"); + } + assert!(out.html.contains("iso-hamstereye")); + assert!(out.html.contains("iso-hamstereyec")); + // everything under 2 m is drawn ten times bigger and scaled back (the 0.1-unit grid + // of the drawing would make it blocky): all but the 8 m wheel + assert_eq!(n(" scale(0.1) "), 7, "view {view}"); + } + let b = r.build("site", 0, &cfg(), &doc).unwrap(); + // life size: a hamster about 17 cm long and 10 cm high, a 30 cm wheel on its stand; + // the sort boxes are real size, not the ten times bigger drawing + let h = b.boxes["h1"]; + assert!(h[3] - h[0] > 0.15 && h[3] - h[0] < 0.2, "{h:?}"); + assert!(h[5] > 0.09 && h[5] < 0.12, "{h:?}"); + let w = b.boxes["w"]; + assert!(w[5] > 0.3 && w[5] < 0.34, "{w:?}"); + assert!(b.boxes["big"][5] > 8.0 && b.boxes["big"][5] < 8.3); + let out = render(&b.scene, 0, &RenderOpts::default()); + let row = |id: &str, k: &str| { + out.info[id] + .rows + .iter() + .find(|r| r.0 == k) + .map(|r| r.1.clone()) + .unwrap_or_default() + }; + assert_eq!(row("h3", "Animals"), "3 hamsters, running"); + assert_eq!(row("hz", "Animals"), "1 hamster, asleep"); + assert_eq!(row("w", "Wheel"), "30 cm across, a turn every 9.6 s"); + assert_eq!(row("big", "Wheel"), "8 m across, standing still"); +} + #[test] fn diagonal_walls_fences_and_hedges_mitre_and_sort() { let r = Runtime::new(&Bundle::default()).unwrap(); diff --git a/crates/isokit-script/stdlib/wildlife.rhai b/crates/isokit-script/stdlib/wildlife.rhai index 161d875..00ce313 100644 --- a/crates/isokit-script/stdlib/wildlife.rhai +++ b/crates/isokit-script/stdlib/wildlife.rhai @@ -1,12 +1,14 @@ -// isokit/wildlife: animals (deer, wild boar, fox, elephant, rabbits, a flock -// of birds, butterflies) and beehives, bushes, flowers, rocks and logs. +// isokit/wildlife: animals (deer, wild boar, fox, elephant, rabbits, +// hamsters and a hamster wheel, a flock of birds, butterflies) and beehives, +// bushes, flowers, rocks and logs. // // Animals are low-poly solids drawn face by face (`face`: the engine culls // the faces turned away and shades the others), their parts back to front by // depth, so they read right from every side and at any rotation. Herds stand // at scattered spots with their own headings, each animal its own sprite. // Grazing animals take turns between two head poses (a CSS flipbook, paused -// like every animation); birds, butterflies and bees fly round on orbits. +// like every animation); running hamsters move their legs in a flipbook of +// four poses; birds, butterflies and bees fly round on orbits. fn animal_kinds() { ["deer", "boar", "fox", "elephant", "rabbits"] } @@ -30,6 +32,26 @@ fn vunit(a) { /// limbs, necks, heads, trunks, logs. The rings keep their turn along a bent /// spine (parallel transport), so trunks and tails do not twist. fn loft(spine, rs, n, mats) { + let rings = loft_rings(spine, rs, n); + let m = spine.len(); + for i in 1..m { + let p = rings[i - 1]; + let q = rings[i]; + for k in 0..n { + let j = (k + 1) % n; + this.face([p[k], p[j], q[j], q[k]], mats[0]); + } + } + let c0 = rings[0]; + c0.reverse(); + this.face(c0, mats[1]); + this.face(rings[m - 1], mats[2]); + this +} + +/// The rings of a loft: n points round each spine point, the first one to +/// the spine's left (towards +x on an upright spine), then up and round. +fn loft_rings(spine, rs, n) { let m = spine.len(); let rings = []; let v1 = (); @@ -53,27 +75,18 @@ fn loft(spine, rs, n, mats) { } rings.push(ring); } - for i in 1..m { - let p = rings[i - 1]; - let q = rings[i]; - for k in 0..n { - let j = (k + 1) % n; - this.face([p[k], p[j], q[j], q[k]], mats[0]); - } - } - let c0 = rings[0]; - c0.reverse(); - this.face(c0, mats[1]); - this.face(rings[m - 1], mats[2]); - this + rings } /// A tapered tube from a to b, radii ra and rb, n sides. fn tube(a, b, ra, rb, n, mat) { this.loft([a, b], [ra, rb], n, [mat, mat, mat]) } // Parts of a creature, drawn back to front: #{ at, t, ... } with t "loft" -// (spine, rs, n, mats), "face" (pts, mat), "line" (pts, cls) or "alt" (two -// part lists shown in turn: a, b, css). +// (spine, rs, n, mats), "face" (pts, mat), "line" (pts, cls), "alt" (two +// part lists shown in turn: a, b, css), "loft2" (a loft in two colours, +// see `loft2`), "eye" (a bead of radius r at `at`, or shut when `closed`) +// or "flip" (a flipbook: vars, a part list per pose, shown 0.1 s each, +// `delay` s in; 4 poses). fn p_loft(spine, rs, n, mats) { #{ at: spine[spine.len() / 2], t: "loft", spine: spine, rs: rs, n: n, mats: mats } } fn p_tube(a, b, ra, rb, n, mat) { #{ at: vmul(vadd(a, b), 0.5), t: "loft", spine: [a, b], rs: [ra, rb], n: n, mats: [mat, mat, mat] } } fn p_face(pts, mat) { #{ at: pts[0], t: "face", pts: pts, mat: mat } } @@ -102,11 +115,38 @@ fn draw_parts(parts) { this.begin("a pose2", p.css); this.draw_parts(p.b); this.end(); + } else if p.t == "loft2" { + this.loft2(p); + } else if p.t == "eye" { + let c = p.at; + if p.closed { + let d = p.r * 0.9; + this.polyline([[c[0] - d, c[1], c[2] + d * 0.15], [c[0], c[1], c[2] - d * 0.3], [c[0] + d, c[1], c[2] + d * 0.15]], 0.0, "hamstereyec"); + } else { + let r = p.r * px(); + this.circle(c[0], c[1], c[2], r, "hamstereye"); + this.circle(c[0], c[1], c[2], r * 0.36, "hamsterglint", -r * 0.3, -r * 0.34); + } + } else if p.t == "flip" { + let np = p.vars.len(); + for k in 0..np { + this.begin(flip_cls(k, np), flip_css(k, np, p.delay)); + this.draw_parts(p.vars[k]); + this.end(); + } } } this } +/// Class and style of pose k in a flipbook of np poses shown 0.1 s each, +/// the whole book `delay` s in; pose 0 shows when unanimated. The viewer +/// styles books of 4 poses (`iso-flip4`). +fn flip_cls(k, np) { if k == 0 { `a flip${np} pose0` } else { `a flip${np}` } } +fn flip_css(k, np, delay) { `animation-delay:-${round4(((np - k) % np) * 0.1 + delay)}s` } + +fn round4(x) { (x * 10000.0).round() / 10000.0 } + /// A point of a head: `a` along its way f, `b` to its left, `c` up from it, /// from its base (f points from the neck to the snout, in the x-z plane). fn hp(base, f, a, b, c) { @@ -132,6 +172,54 @@ fn scale_parts(parts, s) { }) } +/// Parts scaled by s about the origin, then moved by the 3D offset o (a +/// hamster of any size anywhere); every kind of part. +fn place_parts(parts, s, o) { + parts.map(|p| { + let q = p; + q.at = vadd(o, vmul(p.at, s)); + if p.t == "loft" || p.t == "loft2" { + q.spine = p.spine.map(|v| vadd(o, vmul(v, s))); + q.rs = p.rs.map(|r| if type_of(r) == "array" { [r[0] * s, r[1] * s] } else { r * s }); + } else if p.t == "face" || p.t == "line" { + q.pts = p.pts.map(|v| vadd(o, vmul(v, s))); + } else if p.t == "eye" { + q.r = p.r * s; + } else if p.t == "alt" { + q.a = place_parts(p.a, s, o); + q.b = place_parts(p.b, s, o); + } else if p.t == "flip" { + q.vars = p.vars.map(|v| place_parts(v, s, o)); + } + q + }) +} + +/// A loft (see `loft`) in two colours, from a part map: the side faces of +/// segment i whose middle points down by more than wt[i] (the sine of the +/// angle below level; -1 none, 1 all) get `low` (belly, chin, muzzle), the +/// others `top`; the end caps cap0 and cap1. Faces carry `beast`. +fn loft2(p) { + let rings = loft_rings(p.spine, p.rs, p.n); + let n = p.n; + let m = rings.len(); + for i in 1..m { + let a = rings[i - 1]; + let b = rings[i]; + let wt = p.wt[i - 1]; + for k in 0..n { + let j = (k + 1) % n; + let tc = 2.0 * PI() * (k + 1) / n; + this.face([a[k], a[j], b[j], b[k]], (if tc.sin() < wt { p.low } else { p.top }) + " beast"); + } + } + let c0 = rings[0]; + c0.reverse(); + this.face(c0, p.cap0 + " beast"); + this.face(rings[m - 1], p.cap1 + " beast"); + this +} + /// Four legs from the underside of a body to the ground: at x fx (front) /// and bx (back), y = +-y, from height z0, radii r0 at the top, r1 at the hoof. fn legs(fx, bx, y, z0, r0, r1, mat) { @@ -468,6 +556,335 @@ fn prefab_butterflies(o) { if high.len() > 0 { s.swim(0.0, 0.0, p.h + 0.4, high); } } +// ---------------------------------------------------------------- hamsters + +// A hamster is built like the animals above, from lofts in two colours (fur +// on top, white underneath), with beady eyes and a flipbook of running legs. +// A hamster wheel's rungs are a flipbook too (they join the wheel's two +// planes, so no rotation can turn them); its spokes turn by a CSS rotation +// in the wheel's own plane (`vplane_g`, the fan's trick). Every animated +// group carries `iso-a`, so the viewer pauses it. + +fn hamster_colours() { ["golden", "grey", "cream", "mixed"] } + +/// The fur material of hamster i for a colour choice ("mixed" takes turns). +fn hamster_fur(c, i) { + let k = if c == "mixed" { ["golden", "grey", "cream"][i % 3] } else { c }; + if k == "grey" { "hamsterg" } else if k == "cream" { "hamsterc" } else { "hamster" } +} + +/// The size of a life-size hamster (about 17 cm) for hamster_parts. +fn hamster_life() { 0.14 } + +/// Raw SVG group that maps the vertical plane along (ux, uy) (plan, per +/// metre) onto the screen: u along it, v straight down, in metres. Draw +/// circles and paths in it (wheels, pulleys) and close it with ``. +fn vplane_g(ux, uy) { + let a = dv(ux * 1000.0, uy * 1000.0, 0.0); + let b = dv(0.0, 0.0, -1000.0); + `` +} + +/// One leg of a hamster facing +x (hamster_parts size), in running pose +/// phi (radians: a paw pushes back on the ground and swings forward +/// lifted) or standing. i: 0/1 front left/right, 2/3 hind left/right. +fn hamster_leg(i, phi, run, n, fur) { + let side = if i % 2 == 0 { 1.0 } else { -1.0 }; + let front = i < 2; + let ph = phi + (if side > 0.0 { 0.0 } else { 0.8 }) + (if front { 0.0 } else { PI() }); + let swing = if run { 0.09 * ph.cos() } else { 0.0 }; + let lift = if run { fmax(0.0, -0.06 * ph.sin()) } else { 0.0 }; + if front { + let y = side * 0.125; + let fx = 0.18 + swing; + [p_tube([0.15, y, 0.2], [fx, y, lift + 0.035], 0.05, 0.035, n, fur), + p_tube([fx - 0.02, y, lift + 0.018], [fx + 0.05, y, lift + 0.014], 0.026, 0.02, n, "hamsterp")] + } else { + let y = side * 0.17; + let bx = -0.3 + swing; + [p_tube([-0.27, y, 0.22], [bx, y, lift + 0.04], 0.075, 0.045, n, fur), + p_tube([bx - 0.04, y, lift + 0.02], [bx + 0.08, y, lift + 0.015], 0.034, 0.026, n, "hamsterp")] + } +} + +/// The parts of a hamster facing +x, feet at z = 0, about 1.2 m long and +/// 0.75 m high (place_parts scales it; hamster_life() is life size): fur +/// material `fur`, white underneath, pink nose, ears and paws. run: the legs +/// in a flipbook of np poses (4), `delay` s in; sleep: lying down with its +/// eyes shut and its legs tucked away. n: sides of body and head (8-10). +fn hamster_parts(run, sleep, n, np, delay, fur) { + let dz = if sleep { -0.07 } else { 0.0 }; + let dh = if sleep { -0.05 } else { 0.0 }; + let parts = []; + // body: an egg, white underneath + parts.push(#{ t: "loft2", at: [-0.12, 0.0, 0.36 + dz], + spine: [[-0.5, 0.0, 0.33 + dz], [-0.45, 0.0, 0.335 + dz], [-0.34, 0.0, 0.345 + dz], [-0.17, 0.0, 0.355 + dz], [0.02, 0.0, 0.365 + dz], [0.16, 0.0, 0.375 + dz], [0.24, 0.0, 0.385 + dz]], + rs: [[0.07, 0.07], [0.19, 0.19], [0.27, 0.27], [0.31, 0.3], [0.3, 0.29], [0.25, 0.25], [0.17, 0.18]], + n: n, top: fur, low: "hamsterw", wt: [-0.5, -0.5, -0.45, -0.45, -0.45, -0.4], cap0: fur, cap1: fur }); + // head with puffed cheek pouches, white muzzle and chin + let hz = 0.42 + dz + dh; + parts.push(#{ t: "loft2", at: [0.38, 0.0, hz], + spine: [[0.12, 0.0, hz], [0.25, 0.0, hz + 0.02], [0.37, 0.0, hz], [0.47, 0.0, hz - 0.04], [0.55, 0.0, hz - 0.09], [0.59, 0.0, hz - 0.11]], + rs: [[0.2, 0.2], [0.255, 0.23], [0.24, 0.21], [0.18, 0.16], [0.1, 0.09], [0.035, 0.035]], + n: n, top: fur, low: "hamsterw", wt: [-0.45, -0.4, -0.15, 0.25, 0.6], cap0: fur, cap1: "hamsterw" }); + // nose, buck teeth, whiskers + parts.push(p_tube([0.58, 0.0, hz - 0.105], [0.61, 0.0, hz - 0.115], 0.03, 0.02, 5, "hamsterp")); + for sd in [1.0, -1.0] { + parts.push(p_tube([0.565, sd * 0.012, hz - 0.15], [0.57, sd * 0.012, hz - 0.2], 0.012, 0.011, 4, "tusk")); + parts.push(#{ t: "line", at: [0.58, sd * 0.1, hz - 0.1], cls: "whisker", + pts: [[0.56, sd * 0.06, hz - 0.11], [0.64, sd * 0.17, hz - 0.07], [0.69, sd * 0.24, hz - 0.06]] }); + parts.push(#{ t: "line", at: [0.58, sd * 0.1, hz - 0.12], cls: "whisker", + pts: [[0.56, sd * 0.06, hz - 0.13], [0.64, sd * 0.18, hz - 0.15], [0.68, sd * 0.25, hz - 0.18]] }); + // round ears: fur behind, pink inside, both anchored on the ear's + // middle (the pink one a little forward), so seen from the front it + // lies on the fur and from behind under it + let ec = [0.22, sd * 0.155, hz + 0.27]; + let ear = []; + for k in 0..7 { + let a = PI() * k / 6.0; + ear.push([ec[0] - 0.01 * a.sin(), ec[1] + sd * 0.095 * a.cos(), ec[2] + 0.095 * a.sin() - 0.045]); + } + let back = p_face(ear.map(|v| [v[0] - 0.005, v[1], v[2]]), fur); + back.at = [ec[0] - 0.005, ec[1], ec[2]]; + parts.push(back); + let inner = p_face(ear.map(|v| [v[0] + 0.02, ec[1] + (v[1] - ec[1]) * 0.62, ec[2] - 0.012 + (v[2] - ec[2]) * 0.62]), "hamsterp"); + inner.at = [ec[0] + 0.03, ec[1], ec[2]]; + parts.push(inner); + // eyes: shiny beads on the sides of the head + parts.push(#{ t: "eye", at: [0.43, sd * 0.172, hz + 0.065], r: 0.05, closed: sleep }); + } + // tail: a stub + parts.push(p_tube([-0.48, 0.0, 0.37 + dz], [-0.555, 0.0, 0.4 + dz], 0.035, 0.02, 4, fur)); + // legs: a flipbook of running poses, or standing; tucked away asleep + if !sleep { + let ln = if n > 8 { 5 } else { 4 }; + for i in 0..4 { + if run { + let vars = []; + for k in 0..np { vars.push(hamster_leg(i, 2.0 * PI() * k / np, true, ln, fur)); } + parts.push(#{ t: "flip", at: vars[0][0].at, vars: vars, delay: delay }); + } else { + parts += hamster_leg(i, 0.0, false, ln, fur); + } + } + } + parts +} + +/// Draws a hamster facing +x with its feet at (x, 0, z): hamster_parts +/// scaled by `size` (hamster_life() is life size), fur material `fur`. +/// run: running (legs in 4 poses, the body bobbing, `delay` s in); sleep: +/// curled up, eyes shut, snoring. Its faces are edged `--bw` wide (set it on +/// a group around it: the default .3 suits a hamster a metre long). +fn hamster_at(x, z, size, run, sleep, fur, delay) { + let parts = place_parts(hamster_parts(run, sleep, if size >= 1.0 { 10 } else { 8 }, 4, delay, fur), size, [x, 0.0, z]); + if run { + this.begin("a runbob", `--hb:-${round4(size * 0.25)}px;animation-delay:-${round4(delay)}s`); + } + this.draw_parts(parts); + if run { + this.end(); + } + if sleep { + let zx = round4(size * 1.2); + // (its delay keeps a still view, paused anywhere, on a visible z Z) + this.raw(x + 0.45 * size, 0.0, z + 0.7 * size, + `` + + `z` + + `Z`); + } + this +} + +/// How many times bigger to draw something `l` metres across: the drawing's +/// coordinates are rounded to a tenth of a screen unit (2.5 cm), which turns +/// a life-size hamster or a 30 cm wheel blocky. See fine_begin. +fn fine_k(l) { if l < 2.0 { 10.0 } else { 1.0 } } + +/// Opens a group that shows what is drawn in it k times smaller about the +/// point (x, y, z) of this frame (fz: the frame's height, the object's z), so +/// draw a small thing k times bigger about that point. css: the group's +/// style (custom properties such as --bw, k times too). Close it with +/// fine_end(), then set the sort extent with ext_reset() and ext_box(): the +/// drawn one is k times too big. +fn fine_begin(x, y, z, fz, k, css) { + if k == 1.0 { + this.raw(``); + } else { + let l0 = to_local(0.0, 0.0); + let a = dv((x - l0[0]) * 1000.0, (y - l0[1]) * 1000.0, (z + fz) * 1000.0); + let sx = round4(a[0] / 1000.0); + let sy = round4(a[1] / 1000.0); + this.raw(``); + } + this +} +fn fine_end() { this.raw("") } + +fn meta_hamster() { + #{ label: "Hamster", category: "Animals", icon: "🐹", + help: "Hamsters (golden, grey or cream, white underneath) standing about, running on the spot or curled up asleep. Life size (about 17 cm) at scale 1; scale them up for a giant.", + props: herd_props(1, 0.5, 30.0) + [ + #{ key: "pose", type: "choice", def: "standing", choices: ["standing", "running", "asleep"], label: "Pose" }, + #{ key: "colour", type: "choice", def: "golden", choices: hamster_colours(), label: "Fur" }, + #{ key: "scale", type: "float", def: 1.0, min: 0.5, max: 40.0, step: 0.5, label: "Scale (× life)" }, + ] } +} +fn prefab_hamster(o) { + let p = o.p; + let n = p.n; + let size = hamster_life() * p.scale; + let k = fine_k(size * 1.2); + let bw = round4(fmin(0.3, size * 0.5) * k); + let seed = o.x * 0.57 + o.y * 1.13; + for i in 0..n { + // a sunflower spread like the herds, each its own sprite + let rr = if n == 1 { 0.0 } else { p.r * ((i + 0.5) / n).sqrt() }; + let ang = i * 2.39996 + hash(seed + i) * 0.6; + let w = world_of(o, rr * ang.cos(), rr * ang.sin()); + let turn = if n == 1 { 0.0 } else { (hash(seed + i * 3.7) - 0.5) * 140.0 }; + frame(w[0], w[1], o.z, o.rot + turn); + let s = sprite(); + s.fine_begin(0.0, 0.0, 0.0, o.z, k, `--bw:${bw}`); + s.hamster_at(0.0, 0.0, size * k, p.pose == "running", p.pose == "asleep", hamster_fur(p.colour, i), round4(hash(seed + i * 2.3) * 3.2)); + s.fine_end(); + s.ext_reset(); + s.ext_box(-0.57 * size, -0.31 * size, 0.7 * size, 0.31 * size, 0.0, 0.75 * size); + } + frame(o); + info_row("Animals", `${n} hamster${if n == 1 { "" } else { "s" }}, ${p.pose}`); +} + +/// A hamster wheel of radius R drawn into this sprite around (0, 0): the +/// drum (0.7 R wide) across local y, open at -y, the spokes and the axle at +/// +y on an A-frame stand, or without the stand on an axle stub for a cage +/// wall at +y. A hamster with fur `fur` runs in it (run: legs, rungs and +/// spokes turning, `delay` s in) or stands still. Returns #{ turn: seconds +/// per turn, ext: [x0, y0, x1, y1, z0, z1] } for the sort extent (raw SVG +/// adds none). Faces are edged `--bw` wide (see hamster_at). +fn hamster_wheel(R, run, stand, fur, delay) { + let W = R * 0.7; + let N = 24; // rungs + let F = 4; // rung poses per rung step + let turn = N * F * 0.1; // s per turn + let lift = if stand { R * 0.12 } else { R * 0.04 }; + let zc = lift + R; + let near = vis(0.0, 1.0); // the stand side is turned to the viewer + let ys = W / 2.0 + R * 0.15; // the stand's plane + let lw = R * 0.05; // the stand's bars + let rimw = round4(R * 0.045); + let hs = R * 0.8; // the hamster's size + let rw = `--rw:${round4(R * 0.028 * px())}`; + + // the hamster and where it sorts against the rungs + let ox = -0.06 * hs; + let oz = lift + 0.035 * hs; + let hk = depth(ox, 0.0) + oz + 0.38 * hs; + + // rungs: for each pose, the ones behind the hamster and the ones in front + let back = []; + let front = []; + let nposes = if run { F } else { 1 }; + for k in 0..nposes { + let b = []; + let f = []; + for j in 0..N { + let a = 2.0 * PI() * (j - k / F.to_float()) / N + 0.1; + let x = R * a.cos(); + let z = zc + R * a.sin(); + let seg = [[x, -W / 2.0, z], [x, W / 2.0, z]]; + if depth(x, 0.0) + z < hk { b.push(seg); } else { f.push(seg); } + } + back.push(b); + front.push(f); + } + + // the stand side: the spoked rim turning in its plane, hub, axle, stand + let dsp = ""; + for i in 0..6 { + let a = 2.0 * PI() * i / 6.0; + dsp += `M0 0L${round4(R * a.cos())} ${round4(R * a.sin())}`; + } + let plane = vplane_g(1.0, 0.0); + let rim = ``; + let spokes = plane + rim + + (if run { `` } else { "" }) + + `` + + ``; + let rim0 = plane + rim + ""; + let ra = R * 0.035; + let yend = if stand { ys + lw * 2.0 } else { ys + R * 0.03 }; + let holder = |s| { + if stand { + let fx = R * 0.62; + s.box(-fx - lw * 2.0, ys - lw * 1.5, fx + lw * 2.0, ys + lw * 1.5, 0.0, lw, "hwheel beast"); + s.beam([-fx, ys, lw], [0.0, ys, zc], [0.0, 1.0, 0.0], lw, "hwheel beast"); + s.beam([fx, ys, lw], [0.0, ys, zc], [0.0, 1.0, 0.0], lw, "hwheel beast"); + s.beam([-fx * 0.55, ys, zc * 0.42], [fx * 0.55, ys, zc * 0.42], [0.0, 1.0, 0.0], lw * 0.8, "hwheel beast"); + } else { + // a bracket for the cage wall + s.box(-R * 0.1, ys, R * 0.1, yend, zc - R * 0.1, zc + R * 0.1, "hwheel beast"); + } + s.tube([0.0, W / 2.0, zc], [0.0, yend, zc], ra, ra, 6, "hwheel beast"); + }; + + let s = this; + if near { + s.raw(0.0, -W / 2.0, zc, rim0); + } else { + holder.call(s); + s.raw(0.0, W / 2.0, zc, spokes); + } + for k in 0..nposes { + if back[k].len() > 0 { + s.begin(if run { flip_cls(k, F) } else { "" }, if run { flip_css(k, F, delay) + ";" + rw } else { rw }); + s.polyline(back[k], 0.0, "wheelrung"); + s.end(); + } + } + s.hamster_at(ox, oz, hs, run, false, fur, delay); + for k in 0..nposes { + if front[k].len() > 0 { + s.begin(if run { flip_cls(k, F) } else { "" }, if run { flip_css(k, F, delay) + ";" + rw } else { rw }); + s.polyline(front[k], 0.0, "wheelrung"); + s.end(); + } + } + if near { + s.raw(0.0, W / 2.0, zc, spokes); + holder.call(s); + } else { + s.raw(0.0, -W / 2.0, zc, rim0); + } + #{ turn: turn, ext: [-R, -W / 2.0, R, yend, 0.0, zc + R] } +} + +fn meta_hamster_wheel() { + #{ label: "Hamster wheel", category: "Animals", icon: "◎", + help: "A hamster running in a wheel: the drum across local y, open at -y, spokes and axle at +y on an A-frame stand (without the stand: an axle stub for a cage wall at +y). Life size by default; make it big for a hamster power plant.", + props: [ + #{ key: "diameter", type: "float", def: 0.3, min: 0.15, max: 12.0, step: 0.05, label: "Diameter (m)" }, + #{ key: "running", type: "bool", def: true, label: "Running" }, + #{ key: "stand", type: "bool", def: true, label: "Stand" }, + #{ key: "colour", type: "choice", def: "golden", choices: ["golden", "grey", "cream"], label: "Fur" }, + ] } +} +fn prefab_hamster_wheel(o) { + let p = o.p; + let delay = round4(hash(o.x * 0.71 + o.y * 1.31) * 2.0); + let k = fine_k(p.diameter); + let s = sprite(); + s.fine_begin(0.0, 0.0, 0.0, o.z, k, `--bw:${round4(fmin(0.3, p.diameter * 0.2) * k)}`); + let w = s.hamster_wheel(p.diameter / 2.0 * k, p.running, p.stand, hamster_fur(p.colour, 0), delay); + s.fine_end(); + s.ext_reset(); + let e = w.ext.map(|v| v / k); + s.ext_box(e[0], e[1], e[2], e[3], e[4], e[5]); + let turn = w.turn; + let across = if p.diameter < 1.0 { `${(p.diameter * 100.0).round().to_int()} cm` } else { `${num1(p.diameter)} m` }; + info_row("Wheel", if p.running { `${across} across, a turn every ${num1(turn)} s` } else { `${across} across, standing still` }); +} + // ---------------------------------------------------------------- beehives fn hive_colours() { ["mixed", "white", "yellow", "blue", "green"] } diff --git a/crates/isokit-theme/themes/default.toml b/crates/isokit-theme/themes/default.toml index f57e5a2..baa360b 100644 --- a/crates/isokit-theme/themes/default.toml +++ b/crates/isokit-theme/themes/default.toml @@ -288,6 +288,15 @@ rabbit = "#8a7a66" eleph = "#6f6b69" elephe = "#625e5c" tusk = "#e9e2d0" +hamster = "#d99a45" +hamsterg = "#8f8a84" +hamsterc = "#e2c58f" +hamsterw = "#ece3d2" +hamsterp = "#e98f9c" +whisker = "rgba(255, 248, 235, .8)" +hamz = "#cfe3ff" +hwheel = "#a7b2c2" +hwhub = "#5d6878" birdd = "#0b0d10" birdw = "#e8e6e0" bee = "#e7b416" @@ -597,6 +606,15 @@ rabbit = "#a08d76" eleph = "#8a8582" elephe = "#7b7673" tusk = "#f3eee0" +hamster = "#e9a650" +hamsterg = "#a29b93" +hamsterc = "#efd6a3" +hamsterw = "#fbf5ea" +hamsterp = "#f29aa7" +whisker = "rgba(90, 66, 40, .6)" +hamz = "#3b5a8a" +hwheel = "#7d8898" +hwhub = "#5d6878" birdd = "#343a44" birdw = "#f6f4ee" bee = "#f0c02a" @@ -647,6 +665,12 @@ rabbit = "rabbit" eleph = "eleph" elephe = "elephe" tusk = "tusk" +hamster = "hamster" +hamsterg = "hamsterg" +hamsterc = "hamsterc" +hamsterw = "hamsterw" +hamsterp = "hamsterp" +hwheel = "hwheel" hive1 = "hive1" hive2 = "hive2" hive3 = "hive3" @@ -924,6 +948,15 @@ f-conc = "conc" ".iso-svg .iso-route" = "fill: none; stroke: var(--iso-route); stroke-width: 1.2; stroke-dasharray: 5 4; stroke-linecap: round; stroke-linejoin: round; opacity: .75" ".iso-svg .iso-antler" = "fill: none; stroke: var(--iso-antler); stroke-width: .35; stroke-linecap: round; stroke-linejoin: round" ".iso-svg .iso-bristle" = "fill: none; stroke: var(--iso-bristle); stroke-width: .5; stroke-linecap: round" +".iso-svg .iso-hamstereye" = "fill: #120d0a; stroke: none" +".iso-svg .iso-hamsterglint" = "fill: #ffffff; stroke: none" +".iso-svg .iso-hamstereyec" = "fill: none; stroke: #2a1d14; stroke-width: calc(var(--bw, .3) * .5); stroke-linecap: round" +".iso-svg .iso-whisker" = "fill: none; stroke: var(--iso-whisker); stroke-width: calc(var(--bw, .3) * .35); stroke-linecap: round" +".iso-svg .iso-hamz" = "fill: var(--iso-hamz); stroke: none; font-weight: 700; font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" +".iso-svg .iso-wheelrim" = "fill: none; stroke: var(--iso-hwheel)" +".iso-svg .iso-wheelspoke" = "fill: none; stroke: var(--iso-hwheel); stroke-linecap: round" +".iso-svg .iso-wheelhub" = "fill: var(--iso-hwhub); stroke: none" +".iso-svg .iso-wheelrung" = "fill: none; stroke: var(--iso-hwheel); stroke-width: var(--rw, .4); stroke-linecap: round" ".iso-svg .iso-bird, .iso-svg .iso-wing" = "stroke: none" ".iso-svg .iso-birdd" = "fill: var(--iso-birdd)" ".iso-svg .iso-birdw" = "fill: var(--iso-birdw)" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 887e44d..9818d88 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -145,6 +145,13 @@ turns while grazing, rabbits hopping; Birds and Butterflies as flocks (`swim` sh out of step); Beehives with bees, Bush, Flowers (bed or meadow), Rocks, Log / stump. `examples/country`, Country demo in the web Studio. +Hamsters (ported from the thaiai.lol easter egg): Hamster (golden, grey or cream, white underneath; +standing, running on the spot or asleep; life size or scaled up) and Hamster wheel (diameter, stand +or cage-wall axle, running or still). Running legs and the wheel's rungs are flipbooks of four +poses, 0.1 s each; the spokes turn by a CSS rotation in the wheel's plane. Things under 2 m are +drawn ten times bigger inside a group that scales them back, so a life-size hamster keeps its +shape on the drawing's 0.1-unit grid. + Routes (`routes.rhai`, `travel` primitive): cars, buses, motorbikes, cyclists, people, deer and elephants along waypoints drawn point by point in the Studio (`shape: "path"`, `pts`); loops or there and back, each way on its side of the line, rounded corners. Every straight piece is a diff --git a/docs/SCRIPTING.md b/docs/SCRIPTING.md index 57b203f..9ab849b 100644 --- a/docs/SCRIPTING.md +++ b/docs/SCRIPTING.md @@ -168,6 +168,25 @@ spread within `r` of the object, each facing its rotation give or take 70°, each its own sprite. Flocks of birds, butterflies and bees use `swim` with a `shape`. +Hamsters add three kinds of parts to `draw_parts`: `loft2` (a loft in two +colours: faces pointing down get the belly colour, see `loft2`), `eye` (a +shiny bead, or shut) and `flip` (a flipbook: one part list per pose, shown +0.1 s each in groups `iso-a iso-flip4 [iso-pose0]`, the step of the viewer's +light animation mode). `hamster_parts(run, sleep, n, np, delay, fur)` builds +one about 1.2 m long, `place_parts(parts, s, o)` scales and moves any part +list, and `s.hamster_at(x, z, size, run, sleep, fur, delay)` draws it +(`hamster_life()` = life size). `s.hamster_wheel(R, run, stand, fur, delay)` +draws a wheel with a hamster in it: the rungs join the wheel's two planes, so +they are a flipbook too, drawn behind or in front of the hamster pose by +pose; the spokes turn by CSS inside `vplane_g(ux, uy)`, a raw group mapping +a vertical plane onto the screen in metres. + +Small things lose their shape to the drawing's 0.1-unit grid (2.5 cm): draw +them `fine_k(size)` times bigger between `s.fine_begin(x, y, z, fz, k, css)` +and `s.fine_end()` (a group that scales them back about the point), then set +the sort extent with `ext_reset()` and `ext_box()`. Screen-unit values +inside (stroke widths, `--bw` for the `beast` edges) are k times bigger too. + ## Points (`pts`) An object's `pts` are plan points relative to its x, y in its turned frame. A prefab that draws diff --git a/viewer/isokit-view.css b/viewer/isokit-view.css index 220fc90..1e7d5f5 100644 --- a/viewer/isokit-view.css +++ b/viewer/isokit-view.css @@ -74,7 +74,7 @@ .iso-sprites path, .iso-sprites ellipse, .iso-sprites circle { stroke: var(--iso-edge); stroke-width: var(--iso-edgew); stroke-linejoin: round; } .iso-ground path, .iso-ground ellipse { stroke: none; } /* animals: each face edged in its own shade (no seams, no wireframe), so a body reads as one surface */ -.iso-sprites .iso-beast { stroke-width: .3; } +.iso-sprites .iso-beast { stroke-width: var(--bw, .3); } .iso-sprites .iso-beast.iso-t { stroke: var(--b, #888); } .iso-sprites .iso-beast.iso-l { stroke: color-mix(in srgb, var(--b, #888) var(--iso-kl), var(--iso-shade)); } .iso-sprites .iso-beast.iso-c { stroke: color-mix(in srgb, var(--b, #888) var(--iso-kc), var(--iso-shade)); } @@ -139,6 +139,13 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; } .iso-svg .iso-flap { transform-box: fill-box; transform-origin: center; animation: iso-flap .8s ease-in-out infinite alternate; } .iso-svg .iso-flutter { transform-box: fill-box; transform-origin: center; animation: iso-flap .3s ease-in-out infinite alternate; } .iso-svg .iso-hop { animation: iso-hop 3.4s ease-out infinite; } +/* hamsters: running legs and wheel rungs are flipbooks of 4 poses, 0.1 s each (delays set the turn; + unanimated the first); spokes turn in the wheel's plane; runners bob, sleepers snore */ +.iso-svg .iso-flip4 { visibility: hidden; animation: iso-flip4 .4s step-end infinite; } +.iso-svg .iso-flip4.iso-pose0 { visibility: visible; } +.iso-svg .iso-wheelspin { transform-box: fill-box; transform-origin: center; animation: iso-spin 9.6s linear infinite; } +.iso-svg .iso-runbob { animation: iso-runbob .4s ease-in-out infinite; } +.iso-svg .iso-zzz { animation: iso-zzz 3.2s ease-out infinite; } .iso-svg .iso-park { display: none; } /* route movers: each piece's copy shows only while a mover is on it (keyframes made per piece); not .iso-a, so unanimated views pause them where they are instead of hiding them */ @@ -168,6 +175,9 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; } @keyframes iso-pose2b { 0% { visibility: hidden; } 65%, 100% { visibility: visible; } } @keyframes iso-flap { from { transform: scaleX(1); } to { transform: scaleX(.25); } } @keyframes iso-hop { 0%, 70%, 100% { transform: translate(0, 0); } 78% { transform: translate(0, -4px); } 86% { transform: translate(0, 0); } 91% { transform: translate(0, -2px); } } +@keyframes iso-flip4 { 0% { visibility: visible; } 25%, 100% { visibility: hidden; } } +@keyframes iso-runbob { 0%, 100% { transform: translate(0, 0); } 50% { transform: translate(0, var(--hb, -1px)); } } +@keyframes iso-zzz { 0% { transform: translate(0, 0); opacity: 0; } 20% { opacity: 1; } 100% { transform: translate(var(--zx, 3px), var(--zy, -7px)); opacity: 0; } } @keyframes iso-ram { 0%, 20%, 100% { transform: translate(0, 0); } 45%, 55% { transform: translate(var(--dx), var(--dy)); } } @keyframes iso-move { from { transform: translate(0, 0); } to { transform: translate(var(--dx), var(--dy)); } } @keyframes iso-shuttle {