code wiki / _hdl_build / nx_vn.nx
nx_vn.nx source
↩ module page · 91 lines · 9767 B
1// nx_vn.nx -- a VISUAL NOVEL engine (the simplest genre, the start of the climb): data-driven branching scenes +
2// choices, emitted as PURE SOVEREIGN STATIC HTML -- ZERO JS, ZERO blit, no third-party. Scenes show via the
3// CSS :target trick (one scene visible at a time; choices are <a href='#sN'>), so it plays in any browser with no
4// script at all -- which is exactly why a VN is the right first genre for the no-blit / publish-to-nishifamily
5// bar. Data-driven = moddable: scenes/choices are DATA the critic also reads. license_tier: ORIGINAL
6import "nx_syscalls.nx"
7
8const VN_NS: i64 = 32 // max scenes
9const VN_MC: i64 = 4 // max choices per scene
10
11// builder over caller arrays: txt[NS] text-ptr, nc[NS] #choices, ct[NS*MC] target-scene, cl[NS*MC] label-ptr, nbox count
12func vn_scene(txt: *i64, nc: *i64, nbox: *i64, text: *u8) -> i64 { let i: i64 = nbox[0]; txt[i] = text as i64; nc[i] = 0; nbox[0] = i + 1; return i }
13func vn_choice(nc: *i64, ct: *i64, cl: *i64, from: i64, label: *u8, to: i64) -> i64 { let j: i64 = nc[from]; ct[from*VN_MC + j] = to; cl[from*VN_MC + j] = label as i64; nc[from] = j + 1; return 0 }
14// word count of a NUL-terminated string (spaces+1).
15func vn_words(s: *u8) -> i64 { if s[0] == (0 as u8) { return 0 } var w: i64 = 1; var i: i64 = 0; while s[i] != (0 as u8) { if s[i] == (32 as u8) { w = w + 1 } i = i + 1 } return w }
16
17func vp(buf: *u8, o: i64, s: *u8) -> i64 { var w: i64 = o; var i: i64 = 0; while s[i] != (0 as u8) { buf[w] = s[i]; w = w + 1; i = i + 1 } return w }
18func vnum(buf: *u8, o: i64, v: i64) -> i64 { var w: i64 = o; var m: i64 = v; if m == 0 { buf[w]=48 as u8; return w+1 } let t: *u8=sys_mmap(24); var k: i64=0; while m>0{t[k]=(48+(m%10)) as u8;m=m/10;k=k+1} var j: i64=0; while j<k{buf[w]=t[k-1-j];w=w+1;j=j+1} return w }
19
20// THE SAMPLE VN (a tiny branching story) -- intentionally has first-draft FLAWS for the critic to catch:
21// (a) s0 opening is too LONG (pacing), (b) s2 has an ILLUSORY choice (both options -> the same scene).
22func vn_sample(txt: *i64, nc: *i64, ct: *i64, cl: *i64, nbox: *i64) -> i64 {
23 let s0: i64 = vn_scene(txt, nc, nbox, "The rain had been falling for three days without pause when you first arrived at the old lighthouse on the edge of the grey northern coast, and as you set down your travel-worn bag upon the damp wooden floor you could not shake the feeling that the keeper who had written to you, in that strange looping hand, had been expecting someone else entirely, someone older perhaps, or someone who already knew the secret that the light was built to hide from the ships and from the town and from the world, and now here you stand, dripping and uncertain, at the foot of a spiral stair that climbs into a darkness no lamp of yours will ever fully answer." as *u8)
24 let s1: i64 = vn_scene(txt, nc, nbox, "You climb. The stair coils tight; salt wind hisses through a cracked pane." as *u8)
25 let s2: i64 = vn_scene(txt, nc, nbox, "You wait by the door instead, listening. Footsteps, slow, descend toward you." as *u8)
26 let s3: i64 = vn_scene(txt, nc, nbox, "At the top, the lamp is dark and cold. A logbook lies open to today's date." as *u8)
27 let s4: i64 = vn_scene(txt, nc, nbox, "Mara -- the keeper -- meets your eyes, kind and bone-tired. 'You came,' she says. 'I half hoped you wouldn't. But I knew someone would answer the letter.'" as *u8)
28 let s5: i64 = vn_scene(txt, nc, nbox, "You light the lamp yourself. Far out, a ship corrects its course. You stayed. -- ENDING: KEEPER" as *u8)
29 let s6: i64 = vn_scene(txt, nc, nbox, "You turn and descend, back into the rain, the secret unkept behind you. -- ENDING: LEAVE" as *u8)
30 vn_choice(nc, ct, cl, s0, "Climb the stair" as *u8, s1)
31 vn_choice(nc, ct, cl, s0, "Wait by the door" as *u8, s2)
32 vn_choice(nc, ct, cl, s1, "Read the logbook" as *u8, s3)
33 vn_choice(nc, ct, cl, s1, "Call out" as *u8, s4)
34 vn_choice(nc, ct, cl, s2, "Open the door" as *u8, s3)
35 vn_choice(nc, ct, cl, s2, "Step back" as *u8, s4)
36 vn_choice(nc, ct, cl, s3, "Light the lamp" as *u8, s5)
37 vn_choice(nc, ct, cl, s4, "Leave her to it" as *u8, s6)
38 return nbox[0]
39}
40
41// SECOND canonical VN -- "The Cartographer's Vow" (engine-owned so the critic gates it too). Designed clean:
42// all scenes reachable, 2 endings, every multi-choice branches, no scene over the pacing threshold.
43func vn_sample2(txt: *i64, nc: *i64, ct: *i64, cl: *i64, nbox: *i64) -> i64 {
44 let s0: i64 = vn_scene(txt, nc, nbox, "You are the last cartographer in Hollowmere, and the commission on your desk asks the impossible: map the Saltwash Road -- a road three witnesses swear they walked, though none has left the town's west gate in forty years. The seal is the magistrate's; the fee, a year's wages. Outside, the fog has not lifted since the request arrived." as *u8)
45 let s1: i64 = vn_scene(txt, nc, nbox, "You shoulder your instruments and walk west. The gate groans open onto packed earth that should not be there -- a road, pale as bone, curving into the fog. Your compass needle turns lazily, refusing north." as *u8)
46 let s2: i64 = vn_scene(txt, nc, nbox, "You climb to the magistrate's house instead. Elder Voss receives you without surprise, as if she had counted the hours. 'You felt it too,' she says. 'The road remembers a town we tried to forget.'" as *u8)
47 let s3: i64 = vn_scene(txt, nc, nbox, "The road ends at a second Hollowmere -- your town, mirrored, lamps lit in windows that are dark at home. A figure at the well looks up wearing your own face." as *u8)
48 let s4: i64 = vn_scene(txt, nc, nbox, "Voss spreads an old chart: Hollowmere, and beside it a twin struck through in red ink. 'We unmade them to spare ourselves a flood that never came. The road is their way back.'" as *u8)
49 let s5: i64 = vn_scene(txt, nc, nbox, "You draw it true -- both towns, the road between -- and sign your name beneath the seal. The fog thins. Hollowmere stops forgetting. -- ENDING: THE TRUE MAP" as *u8)
50 let s6: i64 = vn_scene(txt, nc, nbox, "You hold the commission to your lamp until it blackens and curls. Some roads should stay unmapped. You leave Hollowmere by the east gate at first light. -- ENDING: THE BURNED COMMISSION" as *u8)
51 vn_choice(nc, ct, cl, s0, "Walk the west road" as *u8, s1)
52 vn_choice(nc, ct, cl, s0, "Question Elder Voss first" as *u8, s2)
53 vn_choice(nc, ct, cl, s1, "Cross into the mirror-town" as *u8, s3)
54 vn_choice(nc, ct, cl, s1, "Turn back to the magistrate" as *u8, s4)
55 vn_choice(nc, ct, cl, s2, "Press her for the whole truth" as *u8, s4)
56 vn_choice(nc, ct, cl, s2, "Accept the work and set out" as *u8, s1)
57 vn_choice(nc, ct, cl, s3, "Draw the true map" as *u8, s5)
58 vn_choice(nc, ct, cl, s4, "Burn the commission" as *u8, s6)
59 return nbox[0]
60}
61
62// emit the VN as a pure-HTML zero-JS playable page (CSS :target = one scene at a time). NUL-terminated; returns length.
63func vn_emit_html(txt: *i64, nc: *i64, ct: *i64, cl: *i64, n: i64, title: *u8, buf: *u8) -> i64 {
64 var o: i64 = 0
65 o = vp(buf, o, "<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>" as *u8)
66 o = vp(buf, o, title)
67 o = vp(buf, o, "</title><style>body{margin:0;background:linear-gradient(rgba(8,10,18,.42),rgba(8,10,18,.68)),url('vn_bg.png') center/cover fixed,#0c0e16;color:#e7e9f0;font-family:Georgia,serif;min-height:100vh}.wrap{max-width:680px;margin:0 auto;padding:32px 20px}.title{text-align:center;padding:40px 0 24px;border-bottom:1px solid #232a3d}.title h1{font-size:1.8rem;margin:0 0 10px;font-family:system-ui,sans-serif;letter-spacing:.02em}.begin,.choice{display:inline-block;margin:6px 6px 0;padding:11px 18px;background:#1b2334;color:#cfe2ff;border:1px solid #34405c;border-radius:9px;text-decoration:none}.begin:hover,.choice:hover{background:#27324a}.scene{display:none}.scene:target{display:block;animation:fade .4s ease}@keyframes fade{from{opacity:0}to{opacity:1}}.story{font-size:1.12rem;line-height:1.7;background:#121726;border:1px solid #232a3d;border-radius:12px;padding:22px 24px;margin:22px 0}.choices{display:flex;flex-wrap:wrap;gap:4px}.end{margin-top:18px;color:#9fb3d8;font-style:italic}.foot{margin-top:40px;color:#5c667a;font-size:12px;text-align:center}</style></head><body><div class='wrap'>" as *u8)
68 o = vp(buf, o, "<div class='title'><h1>" as *u8); o = vp(buf, o, title); o = vp(buf, o, "</h1><p style='color:#8aa0c6;margin:0 0 14px;font-family:system-ui,sans-serif;font-size:13px'>A living visual novel · bounded interactions · sovereign zero-JS</p><a class='begin' href='#s0'>Begin ▶</a></div>" as *u8)
69 var i: i64 = 0
70 while i < n {
71 o = vp(buf, o, "<section class='scene' id='s" as *u8); o = vnum(buf, o, i); o = vp(buf, o, "'><div class='story'>" as *u8)
72 o = vp(buf, o, txt[i] as *u8)
73 o = vp(buf, o, "</div>" as *u8)
74 if nc[i] == 0 {
75 o = vp(buf, o, "<div class='end'>◆ The End ◆ <a class='choice' href='#s0'>↻ Replay</a></div>" as *u8)
76 } else {
77 o = vp(buf, o, "<div class='choices'>" as *u8)
78 var j: i64 = 0
79 while j < nc[i] {
80 o = vp(buf, o, "<a class='choice' href='#s" as *u8); o = vnum(buf, o, ct[i*VN_MC + j]); o = vp(buf, o, "'>" as *u8); o = vp(buf, o, cl[i*VN_MC + j] as *u8); o = vp(buf, o, "</a>" as *u8)
81 j = j + 1
82 }
83 o = vp(buf, o, "</div>" as *u8)
84 }
85 o = vp(buf, o, "</section>" as *u8)
86 i = i + 1
87 }
88 o = vp(buf, o, "<div class='foot'>A Nishi visual novel · pure sovereign HTML · zero JavaScript</div></div></body></html>" as *u8)
89 buf[o] = 0 as u8
90 return o
91}