This commit is contained in:
2026-04-02 20:58:48 +02:00
parent a6265e38ca
commit 8e2e712cb0
2 changed files with 279 additions and 18 deletions

View File

@@ -24,13 +24,22 @@ Sourced from books by Aajonus Vonderplanitz. Curated by the owner only — no co
Game-engine style — custom rendering, animations, smooth transitions, physics-based graph layouts. Not a traditional web UI.
## Tech Stack — Under Consideration
## Tech Stack
- **Raylib + C++ + Emscripten** — leading candidate. Mature WASM story, true native binary, lean output.
- **Raylib + Zig** — elegant `@embedFile` for data embedding, but friction between Zig's WASM target and raylib's Emscripten dependency.
- **Mach (Zig game engine)**native + WASM via WebGPU, but pre-1.0 and still maturing.
- **Zig + raylib-zig + Emscripten** — chosen. Native binary and WASM from the same codebase. Package managed via `build.zig.zon`.
- ~~Raylib + C++ + Emscripten~~ — considered, but Zig's `@embedFile` and compile-time data embedding are a better fit.
- ~~Mach (Zig game engine)~~discarded, pre-1.0 and still maturing.
- ~~Three.js + Electron~~ — discarded. Electron too heavy, sidesteps WASM.
## Current Implementation State
Graph navigation is working (`src/main.zig`):
- **Data model**: nodes of five kinds — `body_part`, `ailment`, `recipe`, `ingredient`, `property` — with typed link lists, all hardcoded in the binary.
- **Navigation**: root view shows body systems in a radial ring; clicking any node drills into it, placing it at the centre with its linked nodes in orbit. Right-click or `B` navigates back. Nav history kept in a fixed-size stack.
- **Rendering**: colour-coded circles per node kind, glow on hover, selection ring on the active centre node, edge lines to linked nodes, breadcrumb trail, colour legend, navigation hints.
- **WASM path**: `emscripten_set_main_loop` hook in place; same `drawFrame` callback used for both targets.
## Constraints
- No widget toolkits — fully custom rendered

View File

@@ -1,18 +1,279 @@
const std = @import("std");
const rl = @import("raylib");
const builtin = @import("builtin");
const math = std.math;
const screen_width = 800;
const screen_height = 450;
const SCREEN_W: i32 = 1024;
const SCREEN_H: i32 = 768;
const CX: f32 = @as(f32, @floatFromInt(SCREEN_W)) / 2.0;
const CY: f32 = @as(f32, @floatFromInt(SCREEN_H)) / 2.0;
const NODE_R: f32 = 42.0;
const ORBIT_R: f32 = 220.0;
// ── Data model ───────────────────────────────────────────────────────────────
const NodeKind = enum { body_part, ailment, recipe, ingredient, property };
const NodeDef = struct {
id: u8,
name: [:0]const u8,
kind: NodeKind,
links: []const u8,
};
/// All content is hardcoded and will be embedded in the binary at compile time.
const all_nodes = [_]NodeDef{
// Body systems (04)
.{ .id = 0, .name = "Digestive System", .kind = .body_part, .links = &.{ 10, 11, 12 } },
.{ .id = 1, .name = "Immune System", .kind = .body_part, .links = &.{ 13, 14 } },
.{ .id = 2, .name = "Nervous System", .kind = .body_part, .links = &.{15} },
.{ .id = 3, .name = "Circulatory", .kind = .body_part, .links = &.{ 13, 14 } },
.{ .id = 4, .name = "Endocrine", .kind = .body_part, .links = &.{ 14, 15 } },
// Ailments (1015)
.{ .id = 10, .name = "Poor Digestion", .kind = .ailment, .links = &.{ 20, 21 } },
.{ .id = 11, .name = "Constipation", .kind = .ailment, .links = &.{ 20, 22 } },
.{ .id = 12, .name = "Bloating", .kind = .ailment, .links = &.{21} },
.{ .id = 13, .name = "Inflammation", .kind = .ailment, .links = &.{ 22, 23 } },
.{ .id = 14, .name = "Fatigue", .kind = .ailment, .links = &.{ 21, 23 } },
.{ .id = 15, .name = "Brain Fog", .kind = .ailment, .links = &.{23} },
// Recipes (2023)
.{ .id = 20, .name = "Meat Smoothie", .kind = .recipe, .links = &.{ 30, 31, 32 } },
.{ .id = 21, .name = "Raw Milk Kefir", .kind = .recipe, .links = &.{ 33, 34 } },
.{ .id = 22, .name = "Raw Egg Blend", .kind = .recipe, .links = &.{ 31, 32, 34 } },
.{ .id = 23, .name = "Honey-Butter Mix", .kind = .recipe, .links = &.{ 35, 36 } },
// Ingredients (3036)
.{ .id = 30, .name = "Raw Meat", .kind = .ingredient, .links = &.{ 40, 41 } },
.{ .id = 31, .name = "Raw Eggs", .kind = .ingredient, .links = &.{ 40, 42 } },
.{ .id = 32, .name = "Lemon Juice", .kind = .ingredient, .links = &.{43} },
.{ .id = 33, .name = "Raw Milk", .kind = .ingredient, .links = &.{ 42, 44 } },
.{ .id = 34, .name = "Kefir Grains", .kind = .ingredient, .links = &.{44} },
.{ .id = 35, .name = "Raw Honey", .kind = .ingredient, .links = &.{ 41, 43 } },
.{ .id = 36, .name = "Raw Butter", .kind = .ingredient, .links = &.{ 41, 44 } },
// Properties (4044)
.{ .id = 40, .name = "High Protein", .kind = .property, .links = &.{} },
.{ .id = 41, .name = "Energy Dense", .kind = .property, .links = &.{} },
.{ .id = 42, .name = "Probiotic", .kind = .property, .links = &.{} },
.{ .id = 43, .name = "Alkalizing", .kind = .property, .links = &.{} },
.{ .id = 44, .name = "Enzyme Rich", .kind = .property, .links = &.{} },
};
const root_ids = [_]u8{ 0, 1, 2, 3, 4 };
fn findNode(id: u8) ?*const NodeDef {
for (&all_nodes) |*n| {
if (n.id == id) return n;
}
return null;
}
fn kindColor(kind: NodeKind) rl.Color {
return switch (kind) {
.body_part => rl.Color.init(70, 150, 215, 255),
.ailment => rl.Color.init(210, 75, 65, 255),
.recipe => rl.Color.init(75, 190, 110, 255),
.ingredient => rl.Color.init(215, 170, 50, 255),
.property => rl.Color.init(170, 90, 215, 255),
};
}
// ── Navigation state ──────────────────────────────────────────────────────────
const VisNode = struct { id: u8, x: f32, y: f32, is_center: bool };
const MAX_STACK = 16;
const MAX_VIS = 24;
var nav_stack: [MAX_STACK]u8 = undefined;
var nav_depth: usize = 0;
var vis: [MAX_VIS]VisNode = undefined;
var vis_count: usize = 0;
var hovered_id: ?u8 = null;
var ready: bool = false;
fn buildVis() void {
vis_count = 0;
if (nav_depth == 0) {
// Root view: body systems arranged in a circle
const n = root_ids.len;
const r: f32 = 200.0;
for (root_ids, 0..) |id, i| {
const angle = (2.0 * math.pi * @as(f32, @floatFromInt(i))) /
@as(f32, @floatFromInt(n)) - math.pi / 2.0;
vis[vis_count] = .{
.id = id,
.x = CX + r * @cos(angle),
.y = CY + r * @sin(angle),
.is_center = false,
};
vis_count += 1;
}
} else {
// Drill-down view: selected node at centre, its links in orbit
const cur_id = nav_stack[nav_depth - 1];
const cur = findNode(cur_id) orelse return;
vis[vis_count] = .{ .id = cur_id, .x = CX, .y = CY, .is_center = true };
vis_count += 1;
const n = cur.links.len;
if (n > 0) {
for (cur.links, 0..) |link_id, i| {
const angle = (2.0 * math.pi * @as(f32, @floatFromInt(i))) /
@as(f32, @floatFromInt(n)) - math.pi / 2.0;
vis[vis_count] = .{
.id = link_id,
.x = CX + ORBIT_R * @cos(angle),
.y = CY + ORBIT_R * @sin(angle),
.is_center = false,
};
vis_count += 1;
}
}
}
}
fn visNodeAt(mx: f32, my: f32) ?u8 {
for (vis[0..vis_count]) |vn| {
const dx = mx - vn.x;
const dy = my - vn.y;
if (dx * dx + dy * dy <= NODE_R * NODE_R) return vn.id;
}
return null;
}
// ── Per-frame logic ───────────────────────────────────────────────────────────
fn update() void {
const mx: f32 = @floatFromInt(rl.getMouseX());
const my: f32 = @floatFromInt(rl.getMouseY());
hovered_id = visNodeAt(mx, my);
if (rl.isMouseButtonPressed(.left)) {
if (hovered_id) |id| {
const is_cur = nav_depth > 0 and nav_stack[nav_depth - 1] == id;
if (!is_cur and nav_depth < MAX_STACK) {
nav_stack[nav_depth] = id;
nav_depth += 1;
buildVis();
}
}
}
if (rl.isMouseButtonPressed(.right) or rl.isKeyPressed(.b)) {
if (nav_depth > 0) {
nav_depth -= 1;
buildVis();
}
}
}
fn drawScene() void {
// Edges from centre to orbit
if (nav_depth > 0 and vis_count > 1) {
const c = vis[0];
for (vis[1..vis_count]) |vn| {
rl.drawLineEx(
.{ .x = c.x, .y = c.y },
.{ .x = vn.x, .y = vn.y },
1.5,
rl.Color.init(55, 55, 65, 255),
);
}
}
// Nodes
for (vis[0..vis_count]) |vn| {
const node = findNode(vn.id) orelse continue;
const col = kindColor(node.kind);
const hov = hovered_id != null and hovered_id.? == vn.id;
const r = if (hov) NODE_R + 5.0 else NODE_R;
const ix: i32 = @intFromFloat(vn.x);
const iy: i32 = @intFromFloat(vn.y);
// Soft glow on hover
if (hov) {
rl.drawCircle(ix, iy, r + 12.0, rl.Color.init(col.r, col.g, col.b, 45));
}
rl.drawCircle(ix, iy, r, col);
// White ring marks the current centre node
if (vn.is_center) {
rl.drawCircleLines(ix, iy, r + 5.0, rl.Color.white);
}
// Label below the circle
const fsz: i32 = if (vn.is_center) 14 else 12;
const tw = rl.measureText(node.name, fsz);
const lx = ix - @divTrunc(tw, 2);
const ly = iy + @as(i32, @intFromFloat(r)) + 5;
rl.drawText(node.name, lx, ly, fsz, rl.Color.ray_white);
}
}
fn drawHUD() void {
// Breadcrumb trail
var bx: i32 = 10;
const by: i32 = 10;
rl.drawText("Body Systems", bx, by, 15, rl.Color.gray);
bx += rl.measureText("Body Systems", 15);
for (nav_stack[0..nav_depth]) |id| {
rl.drawText(" > ", bx, by, 15, rl.Color.dark_gray);
bx += rl.measureText(" > ", 15);
const node = findNode(id) orelse continue;
rl.drawText(node.name, bx, by, 15, rl.Color.ray_white);
bx += rl.measureText(node.name, 15);
}
// Navigation hint at bottom
const hint: [:0]const u8 = if (nav_depth > 0)
"[B] / right-click to go back"
else
"Click a body system to explore";
rl.drawText(hint, 10, SCREEN_H - 28, 13, rl.Color.dark_gray);
// Colour legend (top-right)
const legend = [_]struct { kind: NodeKind, label: [:0]const u8 }{
.{ .kind = .body_part, .label = "Body System" },
.{ .kind = .ailment, .label = "Ailment" },
.{ .kind = .recipe, .label = "Recipe" },
.{ .kind = .ingredient, .label = "Ingredient" },
.{ .kind = .property, .label = "Property" },
};
const lx: i32 = SCREEN_W - 140;
var ly: i32 = 10;
for (legend) |e| {
rl.drawCircle(lx + 8, ly + 8, 8.0, kindColor(e.kind));
rl.drawText(e.label, lx + 22, ly + 1, 13, rl.Color.light_gray);
ly += 22;
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
fn drawFrame() callconv(.c) void {
if (!ready) {
buildVis();
ready = true;
}
update();
rl.beginDrawing();
defer rl.endDrawing();
rl.clearBackground(rl.Color.init(12, 12, 18, 255));
drawScene();
drawHUD();
}
pub fn main() anyerror!void {
rl.initWindow(screen_width, screen_height, "primalwiki");
rl.initWindow(SCREEN_W, SCREEN_H, "primalwiki");
defer rl.closeWindow();
if (builtin.os.tag == .emscripten) {
// Browser: emscripten's main loop drives the frame callback
const em = struct {
extern fn emscripten_set_main_loop(func: *const fn () callconv(.c) void, fps: c_int, simulate_infinite_loop: c_int) void;
extern fn emscripten_set_main_loop(
func: *const fn () callconv(.c) void,
fps: c_int,
simulate_infinite_loop: c_int,
) void;
};
em.emscripten_set_main_loop(&drawFrame, 0, 1);
} else {
@@ -22,12 +283,3 @@ pub fn main() anyerror!void {
}
}
}
fn drawFrame() callconv(.c) void {
rl.beginDrawing();
defer rl.endDrawing();
rl.clearBackground(rl.Color.black);
rl.drawText("primalwiki poc", 190, 180, 30, rl.Color.ray_white);
rl.drawText("zig + raylib", 240, 230, 20, rl.Color.dark_gray);
}