quoxcli 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/admin.js +7 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +19 -0
- package/lib/api/endpoints/index.js +2 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agentAttach.js +15 -0
- package/lib/commands/approvals.js +193 -3
- package/lib/commands/ask.js +1 -1
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +15 -0
- package/lib/commands/claude.js +4 -1
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/evidence.js +2 -1
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +78 -5
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +30 -3
- package/lib/commands/lucid.js +1 -0
- package/lib/commands/mail.js +161 -20
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +55 -144
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +259 -0
- package/lib/commands/objective.js +5 -2
- package/lib/commands/offload.js +2 -3
- package/lib/commands/plan.js +17 -0
- package/lib/commands/provider.js +29 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/run.js +2 -1
- package/lib/commands/server.js +37 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/skills.js +4 -1
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +824 -0
- package/lib/commands/wazuhIntake.js +317 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +27 -1
- package/lib/core/commandRegistrars.js +8 -0
- package/lib/core/config.js +28 -2
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/governedSession.js +135 -5
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/offload.js +37 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +1 -1
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/EXTENSIONS.md +142 -19
- package/lib/quoxon/api.js +185 -34
- package/lib/quoxon/app.js +1032 -152
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +56 -2
- package/lib/quoxon/approvalSentence.js +8 -4
- package/lib/quoxon/approvalsView.js +69 -11
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +1 -1
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +7 -0
- package/lib/quoxon/commands.js +40 -10
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +71 -0
- package/lib/quoxon/dragonCompanion.js +37 -7
- package/lib/quoxon/dragonMoments.js +78 -0
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +43 -7
- package/lib/quoxon/extensions.js +398 -38
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/loopCard.js +5 -1
- package/lib/quoxon/loopCards.js +17 -8
- package/lib/quoxon/loopStream.js +20 -9
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +20 -5
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +22 -0
- package/lib/quoxon/print.js +2 -0
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/render.js +63 -14
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +86 -0
- package/lib/quoxon/slash/agents.js +2 -1
- package/lib/quoxon/slash/argSources.js +9 -2
- package/lib/quoxon/slash/args.js +4 -2
- package/lib/quoxon/slash/broadcast.js +18 -5
- package/lib/quoxon/slash/broadcastRun.js +13 -2
- package/lib/quoxon/slash/cliLeaves.json +63 -1
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/palette.js +22 -1
- package/lib/quoxon/slash/paletteController.js +10 -4
- package/lib/quoxon/slash/restart.js +7 -4
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +17 -0
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/suggestCommand.js +1 -1
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +36 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +55 -0
- package/package.json +3 -2
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480">
|
|
2
|
+
<title>Quoxperticus — the white, crimson and obsidian skill keeper</title>
|
|
3
|
+
<g id="wings">
|
|
4
|
+
<path id="left-wing" fill="#a92339" d="M 322 298 Q 252 207 137 92 L 161 209 L 114 182 L 148 279 L 112 291 L 243 337 Z"/>
|
|
5
|
+
<path id="right-wing" fill="#c12b42" d="M 444 296 Q 510 133 621 78 L 608 192 L 666 170 L 634 269 L 677 277 L 533 338 Z"/>
|
|
6
|
+
<path id="wing-ribs" fill="none" stroke="#666777" stroke-width="12" d="M 322 298 L 137 92 L 161 209 L 148 279 M 322 298 L 161 209 M 444 296 L 621 78 L 608 192 L 634 269 M 444 296 L 608 192"/>
|
|
7
|
+
<path id="wing-tips" fill="#f6f0f2" d="M 126 105 L 137 76 L 165 103 L 145 98 Z M 600 91 L 629 66 L 642 99 L 622 86 Z"/>
|
|
8
|
+
</g>
|
|
9
|
+
<g id="tail">
|
|
10
|
+
<path id="tail-coil" fill="#555763" d="M 447 353 C 521 330 611 352 631 391 C 655 437 576 454 521 432 C 578 438 610 413 593 395 Q 566 368 461 387 Z"/>
|
|
11
|
+
<path id="tail-edge" fill="none" stroke="#eeecef" stroke-width="10" d="M 468 367 C 537 346 611 376 619 404 Q 617 428 563 433"/>
|
|
12
|
+
<path id="tail-blade" fill="#e8374d" d="M 523 431 L 494 406 L 504 443 L 543 447 Z"/>
|
|
13
|
+
</g>
|
|
14
|
+
<g id="body">
|
|
15
|
+
<path id="obsidian-body" fill="#55545f" d="M 315 237 Q 393 210 448 257 L 479 347 Q 475 403 431 418 L 311 413 Q 272 378 292 312 Z"/>
|
|
16
|
+
<path id="chest-armour" fill="#e9e4ea" d="M 340 231 L 407 237 L 436 304 L 415 369 L 367 395 L 324 350 L 319 287 Z"/>
|
|
17
|
+
<path id="chest-joints" fill="none" stroke="#8d8696" stroke-width="7" d="M 325 275 L 417 280 M 324 305 L 430 312 M 336 339 L 419 344 M 351 367 L 399 374"/>
|
|
18
|
+
<path id="legs" fill="#666370" d="M 301 341 L 340 367 L 325 407 L 350 435 L 271 435 L 261 414 Z M 425 360 L 465 345 L 483 410 L 500 434 L 420 434 L 405 413 Z"/>
|
|
19
|
+
<path id="feet-armour" fill="#f1edf1" d="M 273 409 L 319 407 L 342 429 L 271 429 Z M 424 408 L 475 408 L 493 428 L 420 429 Z"/>
|
|
20
|
+
<path id="talons" fill="#161923" d="M 282 416 L 278 432 L 290 427 L 300 415 L 300 432 L 312 428 L 318 417 L 325 433 L 337 428 M 433 416 L 430 432 L 442 428 L 451 415 L 453 432 L 465 428 L 472 417 L 480 432 L 490 428"/>
|
|
21
|
+
</g>
|
|
22
|
+
<g id="neck">
|
|
23
|
+
<path id="neck-black" fill="#53515d" d="M 334 166 L 400 160 Q 437 211 433 267 L 389 292 L 335 261 L 348 214 Z"/>
|
|
24
|
+
<path id="neck-white" fill="#f5eef2" d="M 344 186 L 371 187 Q 384 227 409 261 L 386 281 L 353 252 L 360 216 Z"/>
|
|
25
|
+
<path id="neck-spines" fill="#ede6ef" d="M 399 178 L 442 177 L 423 196 L 450 211 L 428 221 L 457 241 L 432 247 L 449 268 L 416 265 Z"/>
|
|
26
|
+
</g>
|
|
27
|
+
<g id="head">
|
|
28
|
+
<path id="horn-far" fill="#faf4f4" d="M 324 117 L 297 76 L 300 26 L 315 65 L 346 105 Z"/>
|
|
29
|
+
<path id="horn-near" fill="#ffffff" d="M 380 108 L 401 59 L 446 22 L 425 71 L 411 124 Z"/>
|
|
30
|
+
<path id="horn-bands" fill="none" stroke="#292b35" stroke-width="9" d="M 300 65 L 321 69 M 312 91 L 334 89 M 408 66 L 430 61 M 397 90 L 417 91"/>
|
|
31
|
+
<path id="face-armour" fill="#e9e5ed" d="M 304 128 L 339 99 L 384 100 L 419 127 L 409 173 L 381 201 L 326 193 L 293 165 Z"/>
|
|
32
|
+
<path id="crown" fill="#e8334e" d="M 337 113 L 341 73 L 358 96 L 376 60 L 379 101 L 397 85 L 392 125 L 365 116 Z"/>
|
|
33
|
+
<path id="cheek-fins" fill="#faf5f7" d="M 393 143 L 455 114 L 427 151 L 457 148 L 426 174 L 438 186 L 399 190 Z"/>
|
|
34
|
+
<path id="muzzle" fill="#ffffff" d="M 313 163 Q 286 153 271 170 L 280 192 L 323 204 L 358 188 L 352 174 Z"/>
|
|
35
|
+
<path id="nostril" fill="#20212b" d="M 282 172 L 293 168 L 290 180 Z"/>
|
|
36
|
+
<path id="mouth" fill="none" stroke="#6a5265" stroke-width="5" d="M 281 187 Q 314 204 347 184"/>
|
|
37
|
+
</g>
|
|
38
|
+
<g id="panel">
|
|
39
|
+
<path id="panel-shell" fill="#6d626f" d="M 186 309 L 416 309 L 442 367 L 211 367 Z"/>
|
|
40
|
+
<path id="panel-screen" fill="#242333" d="M 199 317 L 406 317 L 425 354 L 218 354 Z"/>
|
|
41
|
+
<path id="panel-rim" fill="none" stroke="#ff6475" stroke-width="7" d="M 186 309 L 416 309 L 442 367 L 211 367 Z"/>
|
|
42
|
+
<path id="skill-cards" fill="#f0e9ee" d="M 224 326 L 258 326 L 264 344 L 230 344 Z M 281 326 L 315 326 L 321 344 L 287 344 Z"/>
|
|
43
|
+
<path id="selected-skill" fill="#ff344f" d="M 341 326 L 379 326 L 385 344 L 347 344 Z"/>
|
|
44
|
+
</g>
|
|
45
|
+
<g id="arms">
|
|
46
|
+
<path id="far-arm" fill="#e5dfe8" d="M 319 248 Q 288 235 267 263 L 251 290 L 228 294 L 223 312 L 272 315 L 287 284 L 325 277 Z"/>
|
|
47
|
+
<path id="near-arm" fill="#f7eff2" d="M 412 250 Q 448 247 451 276 L 417 304 L 382 311 L 369 299 L 389 282 L 411 277 Z"/>
|
|
48
|
+
<path id="finger-claws" fill="#20212b" d="M 232 297 L 225 316 L 240 308 L 249 298 L 245 317 L 260 309 M 377 296 L 367 313 L 385 308 L 393 295 L 390 312 L 407 303"/>
|
|
49
|
+
<path id="arm-scales" fill="none" stroke="#a699ac" stroke-width="6" d="M 281 259 L 298 275 M 421 261 L 438 277"/>
|
|
50
|
+
</g>
|
|
51
|
+
<g id="eyes">
|
|
52
|
+
<path id="far-eye-socket" fill="#080d19" d="M 304 147 C 304 137.0588745 312.0588745 129 322 129 C 331.9411255 129 340 137.0588745 340 147 C 340 156.9411255 331.9411255 165 322 165 C 312.0588745 165 304 156.9411255 304 147 Z"/>
|
|
53
|
+
<path id="far-eye-white" fill="#ffffff" d="M 310 147 C 310 140.372583 315.372583 135 322 135 C 328.627417 135 334 140.372583 334 147 C 334 153.627417 328.627417 159 322 159 C 315.372583 159 310 153.627417 310 147 Z"/>
|
|
54
|
+
<path id="far-pupil" fill="#080d19" d="M 318 145 C 318 138 326 138 326 145 C 326 153 318 153 318 145 Z"/>
|
|
55
|
+
<path id="near-eye-socket" fill="#080d19" d="M 349 147 C 349 134.8497355 358.8497355 125 371 125 C 383.1502645 125 393 134.8497355 393 147 C 393 159.1502645 383.1502645 169 371 169 C 358.8497355 169 349 159.1502645 349 147 Z"/>
|
|
56
|
+
<path id="near-eye-white" fill="#ffffff" d="M 356 147 C 356 138.71572875 362.71572875 132 371 132 C 379.28427125 132 386 138.71572875 386 147 C 386 155.28427125 379.28427125 162 371 162 C 362.71572875 162 356 155.28427125 356 147 Z"/>
|
|
57
|
+
<path id="near-pupil" fill="#080d19" d="M 364 145 C 364 137 373 137 373 145 C 373 154 364 154 364 145 Z"/>
|
|
58
|
+
</g>
|
|
59
|
+
</svg>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {renderSvgRig} from '../../../factory/svg-rig-contour.mjs';
|
|
2
|
+
export const QUOXPERTICUS_CLIPS=Object.freeze([{id:'idle',title:'Quiet Expertise',duration:12000},{id:'attach',title:'At Your Command',duration:3000},{id:'skill',title:'Select a Skill',duration:6000}]);
|
|
3
|
+
export const STORY_DURATION=12000;
|
|
4
|
+
const pulse=(t,a,b)=>t<=a||t>=b?0:Math.sin(Math.PI*(t-a)/(b-a))**2;
|
|
5
|
+
const ease=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x);};
|
|
6
|
+
export function poseRig(rig,scene,ms){
|
|
7
|
+
const t=scene==='attach'?ms:ms%(scene==='skill'?6000:12000);
|
|
8
|
+
const look=scene==='skill'?pulse(t,0,3000):scene==='attach'?pulse(t,200,1900):.25*pulse(t,2000,5500);
|
|
9
|
+
const tap=scene==='skill'?(t<1800?ease((t-800)/1000):1-ease((t-2200)/1000)):0;
|
|
10
|
+
const follow=scene==='attach'?pulse(t,700,2800):scene==='idle'?.4*pulse(t,3000,8500):.25*pulse(t,3000,5800);
|
|
11
|
+
const paths=rig.paths.map(p=>({...p,polygons:p.polygons.map(poly=>poly.map(([x,y])=>{
|
|
12
|
+
if(p.group==='head'||p.group==='eyes')return [x-2*look,y+7*look];
|
|
13
|
+
if(p.group==='neck'){const w=Math.max(0,Math.min(1,(285-y)/75));return [x-2*look*w,y+7*look*w];}
|
|
14
|
+
if(p.id==='near-arm'||p.id==='finger-claws'&&x>350||p.id==='arm-scales'&&x>350){const w=Math.max(0,Math.min(1,(y-250)/45));return [x-12*tap*w,y+20*tap*w];}
|
|
15
|
+
if(p.group==='wings'){const w=Math.max(0,Math.min(1,(330-y)/240));return [x+(x<380?4:-4)*follow*w,y+5*follow*w];}
|
|
16
|
+
if(p.group==='tail'){const w=Math.max(0,Math.min(1,(x-460)/150));return [x,y-6*follow*w];}
|
|
17
|
+
return [x,y];
|
|
18
|
+
}))}));
|
|
19
|
+
const active=scene==='skill'&&t>=1800&&t<5000;
|
|
20
|
+
for(const p of paths)if(p.id==='selected-skill')p.rgb=active?[255,Math.round(70+100*pulse(t,1800,5000)),105]:[145,35,55];
|
|
21
|
+
if(active){
|
|
22
|
+
// Trace along the panel's own lower edge, clear of face and claws.
|
|
23
|
+
const u=Math.min(1,(t-1800)/1800),x=222+204*u,y=359;
|
|
24
|
+
paths.push({id:'skill-pulse',group:'effect',rgb:[255,230,240],stroke:0,erase:false,polygons:[[[x-4,y],[x,y-4],[x+4,y],[x,y+4]]]});
|
|
25
|
+
}
|
|
26
|
+
const map=new Map(paths.map(p=>[p.id,p]));
|
|
27
|
+
return {...rig,paths,eyes:rig.eyes.map(e=>({...e,socket:map.get(e.socket.id),white:map.get(e.white.id),pupil:map.get(e.pupil.id)}))};
|
|
28
|
+
}
|
|
29
|
+
export function renderQuoxperticus(rig,scene='idle',ms=0,options={}){
|
|
30
|
+
if(!QUOXPERTICUS_CLIPS.some(c=>c.id===scene))return {ok:false,code:'UNKNOWN_CLIP'};
|
|
31
|
+
if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
|
|
32
|
+
if(scene==='attach'&&ms>=3000&&!options.still)return renderQuoxperticus(rig,'idle',ms-3000,options);
|
|
33
|
+
const t=options.still?({idle:4000,attach:1200,skill:2200}[scene]):ms;
|
|
34
|
+
const caption=scene==='attach'?'Skills at your command.':scene==='skill'?'Select. Illuminate. Connect.':'Ready when you are.';
|
|
35
|
+
return {...renderSvgRig(poseRig(rig,scene,t),options),clip:scene,scene,caption};
|
|
36
|
+
}
|
|
37
|
+
export function renderStory(rig,ms,options={}){
|
|
38
|
+
if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
|
|
39
|
+
if(options.still)return renderQuoxperticus(rig,'skill',2200,options);
|
|
40
|
+
if(ms<3000)return renderQuoxperticus(rig,'attach',ms,options);
|
|
41
|
+
if(ms<9000)return renderQuoxperticus(rig,'skill',ms-3000,options);
|
|
42
|
+
return renderQuoxperticus(rig,'idle',ms-9000,options);
|
|
43
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {renderSvgRig} from '../../../factory/svg-rig-contour.mjs';
|
|
2
|
+
import {poseRig as previousPose,renderQuoxperticus as previous,QUOXPERTICUS_CLIPS as clips} from '../v3/character.mjs';
|
|
3
|
+
export const QUOXPERTICUS_CLIPS=Object.freeze([...clips,{id:'orchestrate',title:'Fleet Conductor',duration:10000}]);
|
|
4
|
+
export const STORY_DURATION=13000;
|
|
5
|
+
const clamp=x=>Math.max(0,Math.min(1,x));
|
|
6
|
+
const ease=x=>{x=clamp(x);return x*x*(3-2*x);};
|
|
7
|
+
const fit=rig=>({...rig,bounds:{left:12,top:0,right:790,bottom:460}});
|
|
8
|
+
export function poseRig(rig,ms,{deck=false}={}){
|
|
9
|
+
const t=ms%10000,energy=ease(t/1400)*(1-ease((t-7800)/2200));
|
|
10
|
+
const base=previousPose(fit(rig),'idle',t);
|
|
11
|
+
const beat=energy*(.75+.25*Math.sin(t/380));
|
|
12
|
+
const paths=base.paths.map(p=>({...p,polygons:p.polygons.map(poly=>poly.map(([x,y])=>{
|
|
13
|
+
if(p.id==='near-arm'||p.id==='finger-claws'&&x>350||p.id==='arm-scales'&&x>350){const w=clamp((450-x)/70);return [x+14*energy*w,y-65*beat*w];}
|
|
14
|
+
return [x,y];
|
|
15
|
+
}))}));
|
|
16
|
+
const back=[],front=[];
|
|
17
|
+
const shape=(list,id,rgb,points,stroke=0)=>list.push({id,group:'orchestration',rgb,polygons:[points],stroke,erase:false});
|
|
18
|
+
const light=(v)=>[Math.round(160+95*v),Math.round(36+170*v),Math.round(65+170*v)];
|
|
19
|
+
const anchor=[390,288-50*beat];
|
|
20
|
+
if(energy>.01){
|
|
21
|
+
const count=deck?6:3;
|
|
22
|
+
for(let i=0;i<count;i++){
|
|
23
|
+
const left=i<3,x=left?55:742,y=110+(i%3)*100;
|
|
24
|
+
const phase=((t-1400-i*230)/2200%1+1)%1;
|
|
25
|
+
const bright=energy*(phase>.78?1:.3);
|
|
26
|
+
shape(back,'rack-'+i,light(bright),[[x-28,y-20],[x+28,y-20],[x+28,y+20],[x-28,y+20],[x-28,y-20]],4);
|
|
27
|
+
shape(back,'rack-divider-'+i,light(bright),[[x-26,y],[x+26,y]],3);
|
|
28
|
+
for(let j=0;j<2;j++)shape(back,'rack-led-'+i+'-'+j,[255,Math.round(130*energy),Math.round(150*energy)],[[x-17,y-12+j*20],[x-11,y-12+j*20],[x-11,y-7+j*20],[x-17,y-7+j*20]]);
|
|
29
|
+
// Curved command threads arch through the space above the wings.
|
|
30
|
+
const control=[left?170:600,25+i%3*22];
|
|
31
|
+
const point=u=>[(1-u)**2*anchor[0]+2*(1-u)*u*control[0]+u*u*x,(1-u)**2*anchor[1]+2*(1-u)*u*control[1]+u*u*y];
|
|
32
|
+
const thread=Array.from({length:31},(_,j)=>point(j/30));
|
|
33
|
+
shape(back,'thread-'+i,[Math.round(180*energy),Math.round(38*energy),Math.round(65*energy)],thread,3);
|
|
34
|
+
if(t>1400+i*230&&t<8500){
|
|
35
|
+
const [wx,wy]=point(phase),r=deck?6:7;
|
|
36
|
+
shape(front,'worker-'+i,light(energy),[[wx-r,wy],[wx,wy-r],[wx+r,wy],[wx,wy+r]]);
|
|
37
|
+
// Short comet tail makes outbound travel readable.
|
|
38
|
+
shape(front,'worker-tail-'+i,[240,65,105],[point(Math.max(0,phase-.055)),[wx,wy]],3);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// A ring at the conducting claw keeps the origin visible.
|
|
42
|
+
shape(front,'command-halo',light(energy),Array.from({length:33},(_,i)=>[anchor[0]+17*Math.cos(i*Math.PI/16),anchor[1]+9*Math.sin(i*Math.PI/16)]),3);
|
|
43
|
+
}
|
|
44
|
+
return {...base,paths:[...back,...paths,...front]};
|
|
45
|
+
}
|
|
46
|
+
export function renderQuoxperticus(rig,scene='idle',ms=0,options={}){
|
|
47
|
+
if(scene!=='orchestrate')return previous(fit(rig),scene,ms,options);
|
|
48
|
+
if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
|
|
49
|
+
const t=options.still?4600:ms%10000;
|
|
50
|
+
return {...renderSvgRig(poseRig(rig,t,options),options),clip:scene,scene,caption:t<1400?'Raise the command.':t<7800?'Dispatch workers. Conduct the fleet.':'Workers settle. The mission continues.',visualOnly:true,demo:true};
|
|
51
|
+
}
|
|
52
|
+
export function renderStory(rig,ms,options={}){
|
|
53
|
+
if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
|
|
54
|
+
if(options.still)return renderQuoxperticus(rig,'orchestrate',4600,options);
|
|
55
|
+
if(ms<3000)return renderQuoxperticus(rig,'attach',ms,options);
|
|
56
|
+
return renderQuoxperticus(rig,'orchestrate',ms-3000,options);
|
|
57
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import {parseSvgRig} from '../../../factory/svg-rig.mjs';
|
|
4
|
+
import {renderQuoxperticus,QUOXPERTICUS_CLIPS} from './character.mjs';
|
|
5
|
+
import {artSlot,composeMessage} from '../../lucid/recovery/layout.mjs';
|
|
6
|
+
import {cellsToAnsi} from '../../../vendor/quox-dragon/qart.js';
|
|
7
|
+
const args=process.argv.slice(2),values=new Set(['--cols','--rows','--terminal-cols','--terminal-rows','--message','--scene','--at']);
|
|
8
|
+
for(let i=0;i<args.length;i++){
|
|
9
|
+
if(values.has(args[i])){if(!args[i+1]||args[i+1].startsWith('--'))throw Error('Missing option value');i++;}
|
|
10
|
+
else if(!['--large','--mono','--help','--watch','--attach','--intro','--still','--list'].includes(args[i]))throw Error('Unknown option: '+args[i]);
|
|
11
|
+
}
|
|
12
|
+
const value=(name,fallback)=>args.includes(name)?args[args.indexOf(name)+1]:fallback;
|
|
13
|
+
if(args.includes('--help')){console.log('Quoxperticus V4 forge studio. --watch --attach --intro --still --scene ID --at MS --list --large --mono --message TEXT --cols N --rows N --terminal-cols N --terminal-rows N');process.exit(0);}
|
|
14
|
+
if(args.includes('--list')){console.log(QUOXPERTICUS_CLIPS.map(c=>c.id+' · '+c.title).join('\n'));process.exit(0);}
|
|
15
|
+
const w=Number(value('--terminal-cols',process.stdout.columns||80)),h=Number(value('--terminal-rows',process.stdout.rows||24));
|
|
16
|
+
if(!Number.isSafeInteger(w)||!Number.isSafeInteger(h)||w<1||h<1)throw Error('Invalid terminal size');
|
|
17
|
+
const slot=artSlot(w,h),large=args.includes('--large')||args.includes('--intro'),explicit=large||args.includes('--cols')||args.includes('--rows');
|
|
18
|
+
const cols=Number(value('--cols',large?Math.min(120,w-4):Math.min(39,slot.cols))),rows=Number(value('--rows',large?Math.min(36,h-4):Math.min(11,slot.rows)));
|
|
19
|
+
const message=value('--message','QUOXPERTICUS\nSkills at your command.');
|
|
20
|
+
if(!slot.showArt&&!explicit){console.log(composeMessage(null,message,{terminalCols:w}).join('\n'));}
|
|
21
|
+
else{
|
|
22
|
+
if(cols>w-4||rows>h-2)throw Error('Requested art exceeds terminal margins');
|
|
23
|
+
const rig=parseSvgRig(await fs.readFile(new URL('../v2/quoxperticus-master.svg',import.meta.url),'utf8'));
|
|
24
|
+
const opts={cols,rows,mono:args.includes('--mono'),still:args.includes('--still'),deck:large};
|
|
25
|
+
const scene=value('--scene',args.includes('--attach')?'attach':'idle');
|
|
26
|
+
const r=renderQuoxperticus(rig,scene,Number(value('--at',0)),opts);
|
|
27
|
+
if(!r.ok)throw Error(r.code);
|
|
28
|
+
if(args.includes('--watch')||args.includes('--attach')||args.includes('--intro')){
|
|
29
|
+
const {watch}=await import('./watch.mjs');
|
|
30
|
+
await watch(rig,opts,{terminalCols:w,terminalRows:h,large,scene:args.includes('--scene')||args.includes('--attach')?scene:null,arrival:args.includes('--attach'),message:args.includes('--message')?message:null});
|
|
31
|
+
}else{
|
|
32
|
+
const shownMessage=args.includes('--message')?message:'QUOXPERTICUS\n'+r.caption;
|
|
33
|
+
console.log((large?[...cellsToAnsi(r.frame),...composeMessage(null,shownMessage,{terminalCols:w})]:composeMessage(r.frame,shownMessage,{terminalCols:w})).join('\n'));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {performance} from 'node:perf_hooks';
|
|
2
|
+
import {QUOXPERTICUS_CLIPS,renderQuoxperticus,renderStory,STORY_DURATION} from './character.mjs';
|
|
3
|
+
import {composeMessage} from '../../lucid/recovery/layout.mjs';
|
|
4
|
+
import {cellsToAnsi} from '../../../vendor/quox-dragon/qart.js';
|
|
5
|
+
|
|
6
|
+
/** One bounded performance. No alternate screen or full-screen clearing. */
|
|
7
|
+
export async function watch(paths,options,config){
|
|
8
|
+
if(!process.stdout.isTTY||!process.stdin.isTTY)throw new Error('--watch/--intro needs an interactive terminal. Use --scene and --at for snapshots.');
|
|
9
|
+
const {terminalCols,terminalRows,scene,large,message}=config;
|
|
10
|
+
const clip=scene?QUOXPERTICUS_CLIPS.find(c=>c.id===scene):null;
|
|
11
|
+
const duration=(clip?.duration??STORY_DURATION)+(config.arrival?6000:0);
|
|
12
|
+
const realCols=process.stdout.columns,realRows=process.stdout.rows;
|
|
13
|
+
const start=performance.now();
|
|
14
|
+
const render=t=>{
|
|
15
|
+
const r=scene?renderQuoxperticus(paths,scene,t,options):renderStory(paths,t,options);
|
|
16
|
+
if(!r?.ok)throw new Error(r?.code??'Invalid story frame');
|
|
17
|
+
const caption=message??'QUOXPERTICUS · '+r.clip+'\n'+r.caption;
|
|
18
|
+
return large?[...cellsToAnsi(r.frame),...caption.split('\n')]:composeMessage(r.frame,caption,{terminalCols});
|
|
19
|
+
};
|
|
20
|
+
// Reserve enough lines for captions too; keep within the real terminal.
|
|
21
|
+
const height=options.rows+3;
|
|
22
|
+
if(height>=terminalRows)throw new Error('Animation needs room for its caption; reduce --rows.');
|
|
23
|
+
let ended=false,drawn=false,timer,resolveDone,rejectDone;
|
|
24
|
+
const wasRaw=Boolean(process.stdin.isRaw);
|
|
25
|
+
const done=new Promise((resolve,reject)=>{resolveDone=resolve;rejectDone=reject;});
|
|
26
|
+
const cleanup=(error)=>{
|
|
27
|
+
if(ended)return;ended=true;clearInterval(timer);
|
|
28
|
+
process.stdin.off('data',key);process.stdout.off('resize',resized);
|
|
29
|
+
process.off('SIGINT',sigint);process.off('SIGTERM',sigterm);
|
|
30
|
+
process.stdin.setRawMode(wasRaw);process.stdin.pause();
|
|
31
|
+
if(drawn)process.stdout.write(`\x1b[${height}B\r`);
|
|
32
|
+
process.stdout.write('\x1b[0m\x1b[?25h');
|
|
33
|
+
error?rejectDone(error):resolveDone();
|
|
34
|
+
};
|
|
35
|
+
const key=data=>{if(data.includes(3)||data.toString().includes('q'))cleanup();};
|
|
36
|
+
const sigint=()=>cleanup(),sigterm=()=>cleanup();
|
|
37
|
+
const resized=()=>cleanup(new Error('Terminal resized; rerun for the new art slot.'));
|
|
38
|
+
try{
|
|
39
|
+
process.stdin.setRawMode(true);process.stdin.resume();process.stdin.on('data',key);
|
|
40
|
+
process.stdout.on('resize',resized);process.on('SIGINT',sigint);process.on('SIGTERM',sigterm);
|
|
41
|
+
process.stdout.write('\x1b[?25l');
|
|
42
|
+
// Allocate the block once; each frame replaces only these lines.
|
|
43
|
+
process.stdout.write('\n'.repeat(height)+`\x1b[${height}A`);drawn=true;
|
|
44
|
+
const tick=()=>{
|
|
45
|
+
try{
|
|
46
|
+
if(process.stdout.columns!==realCols||process.stdout.rows!==realRows)return resized();
|
|
47
|
+
const t=Math.min(performance.now()-start,duration-1),lines=render(t);
|
|
48
|
+
if(lines.length>height)throw new Error('Message exceeds the animation block; use a shorter message or a static preview.');
|
|
49
|
+
process.stdout.write(Array.from({length:height},(_,i)=>'\r\x1b[2K'+(lines[i]??'')+'\n').join('')+`\x1b[${height}A`);
|
|
50
|
+
if(performance.now()-start>=duration)cleanup();
|
|
51
|
+
}catch(e){cleanup(e);}
|
|
52
|
+
};
|
|
53
|
+
tick();if(!ended)timer=setInterval(tick,1000/6);
|
|
54
|
+
await done;
|
|
55
|
+
}catch(e){cleanup(e);await done;}
|
|
56
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {renderSvgRig} from '../../../factory/svg-rig.mjs';
|
|
2
|
+
export const VULCAN_CLIPS=Object.freeze([{id:'idle',title:'Banked Furnace',duration:6000},{id:'fire',title:'Fire and Brimstone',duration:6000}]);
|
|
3
|
+
export const STORY_DURATION=12000;
|
|
4
|
+
export function poseRig(rig,scene,ms){
|
|
5
|
+
const t=ms%6000,heat=(1-Math.cos(t/6000*Math.PI*2))/2;
|
|
6
|
+
const paths=rig.paths.map(p=>({...p,rgb:p.id.includes('lava')||p.id.includes('heat')||p.id.includes('crack')||p.id==='hot-billet'||p.id==='hammer-seam'?[255,Math.round(100+95*heat),Math.round(25+30*heat)]:p.rgb}));
|
|
7
|
+
// Fixed camera includes the complete mouth-to-forge flame envelope.
|
|
8
|
+
if(scene==='fire'){
|
|
9
|
+
const power=Math.sin(Math.PI*t/6000)**2;
|
|
10
|
+
if(power>.02){
|
|
11
|
+
const flame=(id,rgb,points)=>({id,group:'fire',rgb,stroke:0,erase:false,polygons:[points]});
|
|
12
|
+
// Authored muzzle already faces right/down toward the workpiece.
|
|
13
|
+
const tip=[468+153*power,194+113*power],cx=468+87*power,cy=194+57*power;
|
|
14
|
+
paths.push(flame('fire-outer',[255,85,20],[[458,187],[cx,cy-23*power],[tip[0]-9*power,tip[1]-34*power],tip,[tip[0]-45*power,tip[1]-12*power],[cx-13*power,cy+19*power],[458,201]]));
|
|
15
|
+
paths.push(flame('fire-heart',[255,220,92],[[458,192],[cx,cy-5*power],[tip[0]-8*power,tip[1]-6*power],[cx-5*power,cy+7*power],[458,198]]));
|
|
16
|
+
for(let i=0;i<4;i++){
|
|
17
|
+
const phase=(t/1100+i/4)%1,x=595+(i-1.5)*18+phase*12,y=304-phase*64;
|
|
18
|
+
const s=2+2*power;paths.push(flame('brimstone-'+i,[255,211,67],[[x,y-s],[x+s,y],[x,y+s],[x-s,y]]));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return {...rig,paths};
|
|
23
|
+
}
|
|
24
|
+
export function renderVulcan(rig,scene='idle',ms=0,options={}){
|
|
25
|
+
if(!VULCAN_CLIPS.some(c=>c.id===scene))return {ok:false,code:'UNKNOWN_CLIP'};
|
|
26
|
+
if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
|
|
27
|
+
const r=renderSvgRig(poseRig(rig,scene,options.still?3000:ms),options);
|
|
28
|
+
return {...r,scene,clip:scene,caption:scene==='fire'?'Fire and brimstone.':'The furnace waits.'};
|
|
29
|
+
}
|
|
30
|
+
export function renderStory(rig,ms,options={}){
|
|
31
|
+
if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
|
|
32
|
+
const t=options.still?9000:ms%STORY_DURATION;
|
|
33
|
+
return renderVulcan(rig,t<6000?'idle':'fire',t%6000,options);
|
|
34
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480">
|
|
2
|
+
<title>Vulcan — basalt dragon at the brimstone forge</title>
|
|
3
|
+
<g id="tail">
|
|
4
|
+
<path id="tail-rock" fill="#706a71" d="M 297 331 C 212 319 191 391 110 380 L 57 345 L 75 398 Q 167 450 290 398 Z"/>
|
|
5
|
+
<path id="tail-lava" fill="none" stroke="#ff801f" stroke-width="9" d="M 283 365 L 228 363 L 191 399 L 120 398 L 79 375"/>
|
|
6
|
+
<path id="tail-spines" fill="#c1a68c" d="M 80 362 L 53 317 L 112 376 L 121 347 L 152 382 L 176 355 L 190 381 L 214 333 L 234 356 Z"/>
|
|
7
|
+
</g>
|
|
8
|
+
<g id="wings">
|
|
9
|
+
<path id="far-wing" fill="#504953" d="M 366 287 L 349 126 L 293 65 L 300 162 L 241 129 L 278 219 L 252 239 Z"/>
|
|
10
|
+
<path id="near-wing" fill="#7e7273" d="M 302 307 L 231 186 L 146 130 L 159 213 L 112 192 L 154 265 L 123 284 L 229 316 Z"/>
|
|
11
|
+
<path id="wing-lava" fill="none" stroke="#ff8b27" stroke-width="9" d="M 302 307 L 231 186 L 146 130 M 231 186 L 190 255 L 123 284 M 366 287 L 349 126 L 293 65"/>
|
|
12
|
+
</g>
|
|
13
|
+
<g id="body">
|
|
14
|
+
<path id="body-rock" fill="#8b7d78" d="M 290 205 L 367 188 L 433 250 L 452 345 L 416 408 L 291 409 L 255 359 L 263 278 Z"/>
|
|
15
|
+
<path id="shoulder-plate" fill="#b4a08a" d="M 291 214 L 332 190 L 361 222 L 346 259 L 301 266 L 276 240 Z"/>
|
|
16
|
+
<path id="belly-core" fill="#ff6820" d="M 352 255 L 403 268 L 420 329 L 394 381 L 346 366 L 321 312 Z"/>
|
|
17
|
+
<path id="belly-plates" fill="none" stroke="#554953" stroke-width="15" d="M 336 280 L 405 295 M 329 310 L 418 326 M 339 341 L 408 352"/>
|
|
18
|
+
<path id="rock-feet" fill="#a28b7a" d="M 292 345 L 329 365 L 321 410 L 341 430 L 266 430 L 252 410 L 261 370 Z M 397 365 L 434 356 L 453 407 L 476 428 L 390 430 L 378 411 Z"/>
|
|
19
|
+
<path id="claws" fill="#f3dfb0" d="M 268 413 L 255 433 L 282 430 L 290 410 L 287 431 L 309 430 L 315 414 L 321 432 L 343 429 M 398 412 L 386 433 L 409 430 L 420 411 L 419 432 L 442 431 L 447 414 L 454 432 L 479 430"/>
|
|
20
|
+
<path id="body-cracks" fill="none" stroke="#ffbc37" stroke-width="7" d="M 281 269 L 298 300 L 279 327 L 297 345 M 421 271 L 435 305 L 420 329 M 282 379 L 296 397 L 286 414"/>
|
|
21
|
+
</g>
|
|
22
|
+
<g id="neck">
|
|
23
|
+
<path id="neck-rock" fill="#99857a" d="M 338 163 L 397 162 L 430 257 L 375 280 L 327 232 Z"/>
|
|
24
|
+
<path id="neck-lava" fill="none" stroke="#ff9c2f" stroke-width="10" d="M 371 187 L 362 222 L 388 246"/>
|
|
25
|
+
</g>
|
|
26
|
+
<g id="head">
|
|
27
|
+
<path id="horn-far" fill="#d3ba91" d="M 330 131 L 311 89 L 326 33 L 341 92 L 357 115 Z"/>
|
|
28
|
+
<path id="horn-near" fill="#e4c798" d="M 387 119 L 401 65 L 439 34 L 423 90 L 420 138 Z"/>
|
|
29
|
+
<path id="head-rock" fill="#a99786" d="M 325 110 L 370 93 L 414 113 L 443 148 L 429 201 L 385 224 L 339 203 L 312 157 Z"/>
|
|
30
|
+
<path id="crown-plates" fill="#6d6063" d="M 328 112 L 347 83 L 365 110 L 382 77 L 400 112 L 418 94 L 422 134 L 382 123 Z"/>
|
|
31
|
+
<path id="face-cracks" fill="none" stroke="#ffbc37" stroke-width="7" d="M 371 109 L 367 131 L 380 143 M 335 172 L 348 192 L 372 202"/>
|
|
32
|
+
<path id="muzzle" fill="#bf9c7f" d="M 406 156 L 448 158 L 469 177 L 461 198 L 423 211 L 394 193 Z"/>
|
|
33
|
+
<path id="furnace-mouth" fill="#080d19" d="M 419 183 L 464 183 L 458 199 L 423 206 L 410 197 Z"/>
|
|
34
|
+
<path id="mouth-heat" fill="#ff8c22" d="M 423 191 L 459 189 L 452 196 L 427 201 Z"/>
|
|
35
|
+
<path id="nose" fill="#080d19" d="M 448 166 L 458 174 L 446 176 Z"/>
|
|
36
|
+
<path id="brow" fill="none" stroke="#e3c49b" stroke-width="8" d="M 330 140 L 354 135 M 374 137 L 406 143"/>
|
|
37
|
+
</g>
|
|
38
|
+
<g id="tools">
|
|
39
|
+
<path id="hammer-handle" fill="none" stroke="#d5aa77" stroke-width="17" d="M 461 296 L 533 140"/>
|
|
40
|
+
<path id="hammer-head" fill="#9b9290" d="M 493 86 L 565 110 L 580 151 L 559 176 L 485 150 L 474 112 Z"/>
|
|
41
|
+
<path id="hammer-face" fill="#dbb885" d="M 565 110 L 580 151 L 559 176 L 549 135 Z"/>
|
|
42
|
+
<path id="hammer-seam" fill="none" stroke="#ffaf2b" stroke-width="8" d="M 495 102 L 518 121 L 509 142 L 545 157"/>
|
|
43
|
+
<path id="arm" fill="#b59b80" d="M 410 247 L 438 241 L 455 267 L 478 253 L 495 264 L 493 288 L 455 309 L 431 293 Z"/>
|
|
44
|
+
<path id="grip" fill="none" stroke="#554953" stroke-width="5" d="M 471 261 L 488 271 M 466 273 L 485 282"/>
|
|
45
|
+
<path id="anvil" fill="#9a98a4" d="M 519 321 L 690 321 L 737 339 L 682 356 L 654 356 L 638 385 L 649 404 L 680 418 L 680 433 L 549 433 L 549 419 L 580 405 L 591 385 L 575 358 L 544 355 Z"/>
|
|
46
|
+
<path id="anvil-top" fill="none" stroke="#f0d8b2" stroke-width="9" d="M 521 322 L 688 322 L 733 338"/>
|
|
47
|
+
<path id="hot-billet" fill="#ffac2d" d="M 567 307 L 638 307 L 648 319 L 563 319 Z"/>
|
|
48
|
+
</g>
|
|
49
|
+
<g id="eyes">
|
|
50
|
+
<path id="far-eye-socket" fill="#080d19" d="M 325 146 Q 342 133 358 148 L 360 175 Q 342 188 326 173 Z"/>
|
|
51
|
+
<path id="far-eye-white" fill="#ffffff" d="M 331 149 Q 343 141 353 151 L 353 169 Q 342 179 332 169 Z"/>
|
|
52
|
+
<path id="far-pupil" fill="#080d19" d="M 343 160 C 343 152 351 152 351 160 C 351 168 343 168 343 160 Z"/>
|
|
53
|
+
<path id="near-eye-socket" fill="#080d19" d="M 369 145 Q 391 132 412 151 L 413 178 Q 389 193 370 177 Z"/>
|
|
54
|
+
<path id="near-eye-white" fill="#ffffff" d="M 376 149 Q 392 141 406 154 L 406 172 Q 391 183 377 172 Z"/>
|
|
55
|
+
<path id="near-pupil" fill="#080d19" d="M 391 162 C 391 152 401 152 401 162 C 401 172 391 172 391 162 Z"/>
|
|
56
|
+
</g>
|
|
57
|
+
</svg>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {renderSvgRig} from '../../../factory/svg-rig.mjs';
|
|
2
|
+
import {renderVulcan as previous,poseRig as heatRig,VULCAN_CLIPS as previousClips} from '../v1/character.mjs';
|
|
3
|
+
export const VULCAN_CLIPS=Object.freeze([...previousClips,{id:'forge',title:'The Brimstone Forge',duration:9000}]);
|
|
4
|
+
export const STORY_DURATION=9000;
|
|
5
|
+
const ease=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x);};
|
|
6
|
+
export function hammerAngle(t){
|
|
7
|
+
if(t<3000)return 0;
|
|
8
|
+
if(t<3800)return -.15*ease((t-3000)/800);
|
|
9
|
+
if(t<4200)return -.15+1.23*((t-3800)/400)**2;
|
|
10
|
+
if(t<4480)return 1.08-.22*ease((t-4200)/280);
|
|
11
|
+
if(t<4900)return .86+.09*ease((t-4480)/420);
|
|
12
|
+
if(t<7000)return .95*(1-ease((t-4900)/2100));
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
export function poseRig(rig,ms){
|
|
16
|
+
const t=ms%9000;
|
|
17
|
+
const posed=heatRig(rig,t<3000?'fire':'idle',t<3000?t*2:0);
|
|
18
|
+
const a=hammerAngle(t),c=Math.cos(a),s=Math.sin(a);
|
|
19
|
+
const turn=([x,y])=>[478+(x-478)*c-(y-275)*s,275+(x-478)*s+(y-275)*c];
|
|
20
|
+
const moving=new Set(['hammer-handle','hammer-head','hammer-face','hammer-seam','grip']);
|
|
21
|
+
const paths=posed.paths.map(p=>moving.has(p.id)?{...p,polygons:p.polygons.map(poly=>poly.map(turn))}:p);
|
|
22
|
+
const delta=t-4200;
|
|
23
|
+
if(delta>=0&&delta<1000){
|
|
24
|
+
const u=delta/1000;
|
|
25
|
+
for(let i=0;i<9;i++){
|
|
26
|
+
const vx=(i-4)*25,vy=-85-12*(i%3),x=625+vx*u,y=307+vy*u+72*u*u,r=3.5*(1-u)+.5;
|
|
27
|
+
paths.push({id:'strike-spark-'+i,group:'sparks',stroke:0,erase:false,rgb:i%2?[255,233,143]:[255,154,34],polygons:[[[x-r,y],[x,y-r*1.8],[x+r,y],[x,y+r*1.8]]]});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {...posed,paths};
|
|
31
|
+
}
|
|
32
|
+
export function renderVulcan(rig,scene='idle',ms=0,options={}){
|
|
33
|
+
if(scene!=='forge')return previous(rig,scene,ms,options);
|
|
34
|
+
if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
|
|
35
|
+
const t=options.still?4350:ms%9000;
|
|
36
|
+
const caption=t<3000?'Heat the iron.':t<4200?'Raise the hammer.':t<4900?'Fire. Iron. Impact.':t<7000?'Let the sparks settle.':'Ready for the next forging.';
|
|
37
|
+
return {...renderSvgRig(poseRig(rig,t),options),clip:scene,scene,caption};
|
|
38
|
+
}
|
|
39
|
+
export const renderStory=(rig,ms,options={})=>renderVulcan(rig,'forge',ms,options);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {renderSvgRig} from '../../../factory/svg-rig.mjs';
|
|
2
|
+
import {poseRig as thermalPose} from '../v1/character.mjs';
|
|
3
|
+
import {poseRig as forgePose,renderVulcan as previous,VULCAN_CLIPS as oldClips} from '../v2/character.mjs';
|
|
4
|
+
export const VULCAN_CLIPS=Object.freeze([{id:'attach',title:'The Smith Arrives',duration:3600},...oldClips.map(c=>c.id==='idle'?{...c,duration:12000}:c)]);
|
|
5
|
+
export const STORY_DURATION=15600;
|
|
6
|
+
const pulse=(t,start,end)=>t<=start||t>=end?0:Math.sin(Math.PI*(t-start)/(end-start))**2;
|
|
7
|
+
export function poseRig(rig,scene,ms){
|
|
8
|
+
const t=scene==='forge'?ms%9000:scene==='idle'?ms%12000:ms;
|
|
9
|
+
let base=scene==='forge'?forgePose(rig,t):thermalPose(rig,scene==='fire'?'fire':'idle',scene==='attach'?0:t);
|
|
10
|
+
// Keep mouth and target locked while emitting fire. Move only after it dies down.
|
|
11
|
+
const nod=scene==='attach'?pulse(t,300,1900):scene==='forge'?pulse(t,3800,4900):scene==='idle'?.22*pulse(t,2200,5200):0;
|
|
12
|
+
const follow=scene==='attach'?pulse(t,900,3200):scene==='forge'?pulse(t,4200,6800):scene==='idle'?.35*pulse(t,3100,7800):0;
|
|
13
|
+
const salute=scene==='attach'?-.13*pulse(t,1100,2900):0,c=Math.cos(salute),s=Math.sin(salute);
|
|
14
|
+
const tools=new Set(['hammer-handle','hammer-head','hammer-face','hammer-seam','grip']);
|
|
15
|
+
const paths=base.paths.map(p=>({...p,polygons:p.polygons.map(poly=>poly.map(([x,y])=>{
|
|
16
|
+
if(p.group==='head'||p.group==='eyes')return [x+4*nod,y+10*nod];
|
|
17
|
+
if(p.group==='neck'){const w=Math.max(0,Math.min(1,(270-y)/65));return [x+4*nod*w,y+10*nod*w];}
|
|
18
|
+
if(p.group==='wings'){const w=Math.max(0,Math.min(1,(310-y)/170));return [x+7*follow*w,y+5*follow*w];}
|
|
19
|
+
if(p.group==='tail'){const w=Math.max(0,Math.min(1,(270-x)/190));return [x,y-9*follow*w*w];}
|
|
20
|
+
if(tools.has(p.id)&&salute)return [478+(x-478)*c-(y-275)*s,275+(x-478)*s+(y-275)*c];
|
|
21
|
+
return [x,y];
|
|
22
|
+
}))}));
|
|
23
|
+
const byId=new Map(paths.map(p=>[p.id,p]));
|
|
24
|
+
const eyes=rig.eyes.map(e=>({...e,socket:byId.get(e.socket.id),white:byId.get(e.white.id),pupil:byId.get(e.pupil.id)}));
|
|
25
|
+
return {...base,paths,eyes};
|
|
26
|
+
}
|
|
27
|
+
export function renderVulcan(rig,scene='idle',ms=0,options={}){
|
|
28
|
+
if(!VULCAN_CLIPS.some(c=>c.id===scene))return {ok:false,code:'UNKNOWN_CLIP'};
|
|
29
|
+
if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
|
|
30
|
+
if(scene==='attach'&&ms>=3600&&!options.still)return renderVulcan(rig,'idle',ms-3600,options);
|
|
31
|
+
const t=options.still?({attach:1400,idle:4000,forge:4350,fire:3000}[scene]):ms;
|
|
32
|
+
const caption=scene==='attach'?'A nod from the forge.':scene==='idle'?'The furnace waits.':previous(rig,scene,t,options).caption;
|
|
33
|
+
return {...renderSvgRig(poseRig(rig,scene,t),options),clip:scene,scene,caption};
|
|
34
|
+
}
|
|
35
|
+
export function renderStory(rig,ms,options={}){
|
|
36
|
+
if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
|
|
37
|
+
if(options.still)return renderVulcan(rig,'forge',4350,options);
|
|
38
|
+
if(ms<3600)return renderVulcan(rig,'attach',ms,options);
|
|
39
|
+
if(ms<12600)return renderVulcan(rig,'forge',ms-3600,options);
|
|
40
|
+
return renderVulcan(rig,'idle',ms-12600,options);
|
|
41
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import {parseSvgRig} from '../../../factory/svg-rig.mjs';
|
|
4
|
+
import {renderVulcan,VULCAN_CLIPS} from './character.mjs';
|
|
5
|
+
import {artSlot,composeMessage} from '../../lucid/recovery/layout.mjs';
|
|
6
|
+
import {cellsToAnsi} from '../../../vendor/quox-dragon/qart.js';
|
|
7
|
+
const args=process.argv.slice(2),values=new Set(['--cols','--rows','--terminal-cols','--terminal-rows','--message','--scene','--at']);
|
|
8
|
+
for(let i=0;i<args.length;i++){
|
|
9
|
+
if(values.has(args[i])){if(!args[i+1]||args[i+1].startsWith('--'))throw Error('Missing option value');i++;}
|
|
10
|
+
else if(!['--large','--mono','--help','--watch','--attach','--intro','--still','--list'].includes(args[i]))throw Error('Unknown option: '+args[i]);
|
|
11
|
+
}
|
|
12
|
+
const value=(name,fallback)=>args.includes(name)?args[args.indexOf(name)+1]:fallback;
|
|
13
|
+
if(args.includes('--help')){console.log('Vulcan V3 forge studio. --watch --attach --intro --still --scene ID --at MS --list --large --mono --message TEXT --cols N --rows N --terminal-cols N --terminal-rows N');process.exit(0);}
|
|
14
|
+
if(args.includes('--list')){console.log(VULCAN_CLIPS.map(c=>c.id+' · '+c.title).join('\n'));process.exit(0);}
|
|
15
|
+
const w=Number(value('--terminal-cols',process.stdout.columns||80)),h=Number(value('--terminal-rows',process.stdout.rows||24));
|
|
16
|
+
if(!Number.isSafeInteger(w)||!Number.isSafeInteger(h)||w<1||h<1)throw Error('Invalid terminal size');
|
|
17
|
+
const slot=artSlot(w,h),large=args.includes('--large')||args.includes('--intro'),explicit=large||args.includes('--cols')||args.includes('--rows');
|
|
18
|
+
const cols=Number(value('--cols',large?Math.min(120,w-4):Math.min(39,slot.cols))),rows=Number(value('--rows',large?Math.min(36,h-4):Math.min(11,slot.rows)));
|
|
19
|
+
const message=value('--message','VULCAN\nThe furnace waits.');
|
|
20
|
+
if(!slot.showArt&&!explicit){console.log(composeMessage(null,message,{terminalCols:w}).join('\n'));}
|
|
21
|
+
else{
|
|
22
|
+
if(cols>w-4||rows>h-2)throw Error('Requested art exceeds terminal margins');
|
|
23
|
+
const rig=parseSvgRig(await fs.readFile(new URL('../v1/vulcan-master.svg',import.meta.url),'utf8'));
|
|
24
|
+
const opts={cols,rows,mono:args.includes('--mono'),still:args.includes('--still'),deck:large};
|
|
25
|
+
const scene=value('--scene',args.includes('--attach')?'attach':'idle');
|
|
26
|
+
const r=renderVulcan(rig,scene,Number(value('--at',0)),opts);
|
|
27
|
+
if(!r.ok)throw Error(r.code);
|
|
28
|
+
if(args.includes('--watch')||args.includes('--attach')||args.includes('--intro')){
|
|
29
|
+
const {watch}=await import('./watch.mjs');
|
|
30
|
+
await watch(rig,opts,{terminalCols:w,terminalRows:h,large,scene:args.includes('--scene')||args.includes('--attach')?scene:null,arrival:args.includes('--attach'),message:args.includes('--message')?message:null});
|
|
31
|
+
}else{
|
|
32
|
+
const shownMessage=args.includes('--message')?message:'VULCAN\n'+r.caption;
|
|
33
|
+
console.log((large?[...cellsToAnsi(r.frame),...composeMessage(null,shownMessage,{terminalCols:w})]:composeMessage(r.frame,shownMessage,{terminalCols:w})).join('\n'));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {performance} from 'node:perf_hooks';
|
|
2
|
+
import {VULCAN_CLIPS,renderVulcan,renderStory,STORY_DURATION} from './character.mjs';
|
|
3
|
+
import {composeMessage} from '../../lucid/recovery/layout.mjs';
|
|
4
|
+
import {cellsToAnsi} from '../../../vendor/quox-dragon/qart.js';
|
|
5
|
+
|
|
6
|
+
/** One bounded performance. No alternate screen or full-screen clearing. */
|
|
7
|
+
export async function watch(paths,options,config){
|
|
8
|
+
if(!process.stdout.isTTY||!process.stdin.isTTY)throw new Error('--watch/--intro needs an interactive terminal. Use --scene and --at for snapshots.');
|
|
9
|
+
const {terminalCols,terminalRows,scene,large,message}=config;
|
|
10
|
+
const clip=scene?VULCAN_CLIPS.find(c=>c.id===scene):null;
|
|
11
|
+
const duration=(clip?.duration??STORY_DURATION)+(config.arrival?6000:0);
|
|
12
|
+
const realCols=process.stdout.columns,realRows=process.stdout.rows;
|
|
13
|
+
const start=performance.now();
|
|
14
|
+
const render=t=>{
|
|
15
|
+
const r=scene?renderVulcan(paths,scene,t,options):renderStory(paths,t,options);
|
|
16
|
+
if(!r?.ok)throw new Error(r?.code??'Invalid story frame');
|
|
17
|
+
const caption=message??'VULCAN · '+r.clip+'\n'+r.caption;
|
|
18
|
+
return large?[...cellsToAnsi(r.frame),...caption.split('\n')]:composeMessage(r.frame,caption,{terminalCols});
|
|
19
|
+
};
|
|
20
|
+
// Reserve enough lines for captions too; keep within the real terminal.
|
|
21
|
+
const height=options.rows+3;
|
|
22
|
+
if(height>=terminalRows)throw new Error('Animation needs room for its caption; reduce --rows.');
|
|
23
|
+
let ended=false,drawn=false,timer,resolveDone,rejectDone;
|
|
24
|
+
const wasRaw=Boolean(process.stdin.isRaw);
|
|
25
|
+
const done=new Promise((resolve,reject)=>{resolveDone=resolve;rejectDone=reject;});
|
|
26
|
+
const cleanup=(error)=>{
|
|
27
|
+
if(ended)return;ended=true;clearInterval(timer);
|
|
28
|
+
process.stdin.off('data',key);process.stdout.off('resize',resized);
|
|
29
|
+
process.off('SIGINT',sigint);process.off('SIGTERM',sigterm);
|
|
30
|
+
process.stdin.setRawMode(wasRaw);process.stdin.pause();
|
|
31
|
+
if(drawn)process.stdout.write(`\x1b[${height}B\r`);
|
|
32
|
+
process.stdout.write('\x1b[0m\x1b[?25h');
|
|
33
|
+
error?rejectDone(error):resolveDone();
|
|
34
|
+
};
|
|
35
|
+
const key=data=>{if(data.includes(3)||data.toString().includes('q'))cleanup();};
|
|
36
|
+
const sigint=()=>cleanup(),sigterm=()=>cleanup();
|
|
37
|
+
const resized=()=>cleanup(new Error('Terminal resized; rerun for the new art slot.'));
|
|
38
|
+
try{
|
|
39
|
+
process.stdin.setRawMode(true);process.stdin.resume();process.stdin.on('data',key);
|
|
40
|
+
process.stdout.on('resize',resized);process.on('SIGINT',sigint);process.on('SIGTERM',sigterm);
|
|
41
|
+
process.stdout.write('\x1b[?25l');
|
|
42
|
+
// Allocate the block once; each frame replaces only these lines.
|
|
43
|
+
process.stdout.write('\n'.repeat(height)+`\x1b[${height}A`);drawn=true;
|
|
44
|
+
const tick=()=>{
|
|
45
|
+
try{
|
|
46
|
+
if(process.stdout.columns!==realCols||process.stdout.rows!==realRows)return resized();
|
|
47
|
+
const t=Math.min(performance.now()-start,duration-1),lines=render(t);
|
|
48
|
+
if(lines.length>height)throw new Error('Message exceeds the animation block; use a shorter message or a static preview.');
|
|
49
|
+
process.stdout.write(Array.from({length:height},(_,i)=>'\r\x1b[2K'+(lines[i]??'')+'\n').join('')+`\x1b[${height}A`);
|
|
50
|
+
if(performance.now()-start>=duration)cleanup();
|
|
51
|
+
}catch(e){cleanup(e);}
|
|
52
|
+
};
|
|
53
|
+
tick();if(!ended)timer=setInterval(tick,1000/6);
|
|
54
|
+
await done;
|
|
55
|
+
}catch(e){cleanup(e);await done;}
|
|
56
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Shared authored SVG → original Q cells, supporting independently owned eyes. */
|
|
2
|
+
import {flattenPath,sampleShape} from '../design/stage3/svg-coverage.js';
|
|
3
|
+
import {makeField,fieldToCells} from '../vendor/quox-dragon/qart.js';
|
|
4
|
+
const attr=(s,k)=>s.match(new RegExp(`(?:^|\\s)${k}="([^"]*)"`))?.[1];
|
|
5
|
+
export function parseSvgRig(svg){
|
|
6
|
+
if(!svg.includes('viewBox="0 0 800 480"'))throw Error('Expected authored 800x480 master');
|
|
7
|
+
const ids=new Set(),groups=new Map(),stack=[];
|
|
8
|
+
for(const [tag]of svg.matchAll(/<g\s[^>]*>|<\/g>|<path\s[^>]*\/>/g)){
|
|
9
|
+
if(tag.startsWith('<g '))stack.push(attr(tag,'id'));else if(tag==='</g>')stack.pop();else groups.set(attr(tag,'id'),stack.at(-1));
|
|
10
|
+
}
|
|
11
|
+
const paths=[...svg.matchAll(/<path\s[^>]*\/>/g)].map(([tag])=>{
|
|
12
|
+
const id=attr(tag,'id'),d=attr(tag,'d'),stroke=attr(tag,'stroke'),fill=attr(tag,'fill');
|
|
13
|
+
if(!id||ids.has(id)||!d)throw Error('Every path requires unique id and geometry');ids.add(id);
|
|
14
|
+
const colour=stroke||fill;if(!/^#[0-9a-f]{6}$/i.test(colour))throw Error('Explicit hex colour required');
|
|
15
|
+
const width=stroke?Number(attr(tag,'stroke-width')):0;
|
|
16
|
+
if(!Number.isFinite(width)||width<0||width>30)throw Error('Invalid stroke width');
|
|
17
|
+
return {id,group:groups.get(id),polygons:flattenPath(d),rgb:[1,3,5].map(i=>parseInt(colour.slice(i,i+2),16)),stroke:width,erase:colour==='#080d19'};
|
|
18
|
+
});
|
|
19
|
+
const eyes=paths.filter(p=>p.id.endsWith('-eye-socket')).map(p=>{
|
|
20
|
+
const name=p.id.slice(0,-11),white=paths.find(p=>p.id===name+'-eye-white'),pupil=paths.find(p=>p.id===name+'-pupil');
|
|
21
|
+
if(!white||!pupil)throw Error('Every eye needs white and pupil');return {name,socket:p,white,pupil};
|
|
22
|
+
});
|
|
23
|
+
if(!paths.length||!eyes.length)throw Error('Missing anatomy or eyes');
|
|
24
|
+
const points=paths.flatMap(p=>p.polygons.flat());
|
|
25
|
+
const x=points.map(p=>p[0]),y=points.map(p=>p[1]);
|
|
26
|
+
return {paths,eyes,bounds:{left:Math.min(...x),top:Math.min(...y),right:Math.max(...x),bottom:Math.max(...y)}};
|
|
27
|
+
}
|
|
28
|
+
export function renderSvgRig(rig,{cols=39,rows=11,mono=false}={}){
|
|
29
|
+
if(!Number.isSafeInteger(cols)||!Number.isSafeInteger(rows)||cols<28||rows<10||cols>200||rows>72)throw RangeError('Art needs 28–200 columns and 10–72 rows');
|
|
30
|
+
if(typeof mono!=='boolean')throw TypeError('mono must be boolean');
|
|
31
|
+
const W=cols*2,H=rows*4,{left,top,right,bottom}=rig.bounds;
|
|
32
|
+
const scale=Math.min((W-4)/(right-left),(H-4)/(bottom-top));
|
|
33
|
+
const geometry={W,H,scale,ox:(W-(right-left)*scale)/2-left*scale,oy:(H-(bottom-top)*scale)/2-top*scale};
|
|
34
|
+
const field=makeField(W,H),labels=new Array(W*H).fill(''),colour=new Array(W*H).fill(null);
|
|
35
|
+
const coverage=p=>{const indices=[];sampleShape(p.polygons,{...geometry,stroke:p.stroke},(x,y)=>indices.push(y*W+x));return indices;};
|
|
36
|
+
const eyeIds=new Set(rig.eyes.flatMap(e=>[e.socket.id,e.white.id,e.pupil.id]));
|
|
37
|
+
function paint(p){for(const i of coverage(p)){field.v[i]=p.erase?0:1;labels[i]=p.id;colour[i]=p.rgb;}}
|
|
38
|
+
for(const p of rig.paths)if(!eyeIds.has(p.id))paint(p);
|
|
39
|
+
// Track eye cells for diagnostics; carve only the curved socket coverage.
|
|
40
|
+
const owned=new Set();
|
|
41
|
+
for(const e of rig.eyes)for(const i of coverage(e.socket))owned.add(Math.floor(i%W/2)+Math.floor(Math.floor(i/W)/4)*cols);
|
|
42
|
+
// Preserve facial dots outside each authored socket. Cell colour still prioritizes eye white.
|
|
43
|
+
for(const e of rig.eyes)paint(e.socket);
|
|
44
|
+
const pupilDots={};
|
|
45
|
+
for(const e of rig.eyes){
|
|
46
|
+
paint(e.white);let dots=coverage(e.pupil);
|
|
47
|
+
if(!dots.length){
|
|
48
|
+
// Optical correction is local to THIS eye; no Lucid-specific coordinates.
|
|
49
|
+
const points=e.pupil.polygons.flat(),cx=points.reduce((n,p)=>n+p[0],0)/points.length,cy=points.reduce((n,p)=>n+p[1],0)/points.length;
|
|
50
|
+
let best=-1,d=Infinity;
|
|
51
|
+
for(let i=0;i<labels.length;i++)if(labels[i]===e.white.id&&field.v[i]){const q=(i%W+.5-(geometry.ox+cx*scale))**2+(Math.floor(i/W)+.5-(geometry.oy+cy*scale))**2;if(q<d){best=i;d=q;}}
|
|
52
|
+
if(best>=0)dots=[best];
|
|
53
|
+
}
|
|
54
|
+
for(const i of dots){field.v[i]=0;labels[i]=e.pupil.id;colour[i]=null;}
|
|
55
|
+
pupilDots[e.name]=dots.length;
|
|
56
|
+
}
|
|
57
|
+
const frame=fieldToCells(field,{color:false,quant:false});let ink=0;
|
|
58
|
+
for(let y=0;y<rows;y++)for(let x=0;x<cols;x++){
|
|
59
|
+
const c=frame[y][x];c.bg=null;c.rgb=null;let chosen=-1,rank=-1;
|
|
60
|
+
for(let dy=0;dy<4;dy++)for(let dx=0;dx<2;dx++){const i=(y*4+dy)*W+x*2+dx;if(!field.v[i])continue;const r=labels[i].endsWith('-eye-white')?2:1;if(r>rank){rank=r;chosen=i;}}
|
|
61
|
+
if(chosen>=0){ink++;if(!mono)c.rgb=[...colour[chosen]];}
|
|
62
|
+
}
|
|
63
|
+
return {ok:true,frame,geometry,metrics:{cols,rows,ink,pupilDots,eyeOwnedCells:owned.size},visualOnly:true,demo:true};
|
|
64
|
+
}
|