diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index 2ea1de0..9463c2c 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -74,6 +74,10 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'Heiji Kazuha lifecycle validation failed' } python tests/plugins/heiji_kazuha_replay.py if ($LASTEXITCODE -ne 0) { throw 'Heiji Kazuha input replay failed' } + python tools/plugins/validate.py plugins/zelda-guardian + if ($LASTEXITCODE -ne 0) { throw 'Zelda lifecycle validation failed' } + python tests/plugins/zelda_replay.py + if ($LASTEXITCODE -ne 0) { throw 'Zelda input replay failed' } python tools/plugins/preview.py if ($LASTEXITCODE -ne 0) { throw 'CPU preview failed' } build/plugins/Release/plugin-surface-probe.exe build/plugins/Release/miucs-plugin-worker.exe plugins build/plugin-fixture.json diff --git a/README.md b/README.md index 8842a9d..34286d6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ > 预览使用合成音乐与按键输入,经正式 Lua/Skia 绘制器生成;GIF 无声,不是桌面录屏。 -## 四个桌面伙伴 +## 五个桌面伙伴 | 插件 | 效果 | 详细说明 | | --- | --- | --- | @@ -18,8 +18,9 @@ | 麦芬 | 两只线条小狗随打字跳跃,麦芬随节拍吹泡泡 | [麦芬](plugins/muffin-puppies/README.md) | | 牛来 | 牛蹄响应按键,上方角色随音乐和打字运动 | [牛来](plugins/niulai-keyboard/README.md) | | 平次与和叶 | 平次随打字挥木剑,和叶随节拍转身 | [平次与和叶](plugins/heiji-kazuha/README.md) | +| 塞尔达 | 林克随按键挥剑、举盾,守护者随拍点发射激光 | [塞尔达](plugins/zelda-guardian/README.md) | -[查看四个插件的动态预览](docs/plugin-previews.md)。首次启动默认选择奶牛猫;之后记住你的选择。每个插件分别保存位置和大小,切换不重启输入采集。 +[查看五个插件的动态预览](docs/plugin-previews.md)。首次启动默认选择奶牛猫;之后记住你的选择。每个插件分别保存位置和大小,切换不重启输入采集。 ## 构建与启动 @@ -40,7 +41,7 @@ open dist/Miucs.app ## 常用操作 -- **可视化**:切换四个桌面伙伴,奶牛猫排在首位。 +- **可视化**:切换五个桌面伙伴,奶牛猫排在首位。 - **显示 / 隐藏画面**:控制浮窗显示。 - **画面大小、不透明度**:等比缩放,调整 10%~100% 不透明度。 - **调整位置与大小**:拖动画面或右下角缩放,完成后恢复鼠标穿透;也可悬停 3 秒点击小锁。 @@ -48,7 +49,7 @@ open dist/Miucs.app ## 插件与隐私 -应用内置四个 Lua 5.4/Skia 插件,也可安装自己编写的插件。首次构建自动下载并校验运行库依赖。开发和调试见 [插件开发指南](docs/plugin-development.md) 与 [插件工作台](docs/plugin-lab.md)。 +应用内置五个 Lua 5.4/Skia 插件,也可安装自己编写的插件。首次构建自动下载并校验运行库依赖。开发和调试见 [插件开发指南](docs/plugin-development.md) 与 [插件工作台](docs/plugin-lab.md)。 音频与物理按键只在本机分析,不依赖网络模型;应用不保存真实音频、屏幕或输入文字。 diff --git a/apps/windows/WindowsSmokeTests.cs b/apps/windows/WindowsSmokeTests.cs index b305ed6..805df5a 100644 --- a/apps/windows/WindowsSmokeTests.cs +++ b/apps/windows/WindowsSmokeTests.cs @@ -24,7 +24,7 @@ public static void Run(string output) private static void VerifyPluginRendering() { using var host = new PluginHost(new AudioChannel(), new KeyboardChannel()); - foreach (string id in new[] { "pixel-keyboard", "muffin-puppies", "niulai-keyboard", "heiji-kazuha" }) + foreach (string id in new[] { "pixel-keyboard", "muffin-puppies", "niulai-keyboard", "heiji-kazuha", "zelda-guardian" }) { if (!host.Views.ContainsKey(id)) throw new InvalidOperationException("Missing bundled plugin: " + id); using var view = new PluginView(host, id); diff --git a/docs/concepts/zelda-guardian/frame-registration.json b/docs/concepts/zelda-guardian/frame-registration.json new file mode 100644 index 0000000..36c763b --- /dev/null +++ b/docs/concepts/zelda-guardian/frame-registration.json @@ -0,0 +1,566 @@ +{ + "link": { + "source": "assets/link-actions.png", + "scale": 0.64, + "rects": [ + [ + 0, + 0, + 343, + 337 + ], + [ + 343, + 0, + 308, + 337 + ], + [ + 651, + 0, + 289, + 337 + ], + [ + 940, + 0, + 322, + 337 + ], + [ + 0, + 337, + 343, + 313 + ], + [ + 343, + 337, + 308, + 313 + ], + [ + 651, + 337, + 289, + 313 + ], + [ + 940, + 337, + 322, + 313 + ], + [ + 0, + 650, + 343, + 284 + ], + [ + 343, + 650, + 308, + 284 + ], + [ + 651, + 650, + 289, + 284 + ], + [ + 940, + 650, + 322, + 284 + ], + [ + 0, + 934, + 343, + 312 + ], + [ + 343, + 934, + 308, + 312 + ], + [ + 651, + 934, + 289, + 312 + ], + [ + 940, + 934, + 322, + 312 + ] + ], + "ground": [ + [ + 168, + 293 + ], + [ + 473, + 293 + ], + [ + 758, + 293 + ], + [ + 1037, + 293 + ], + [ + 168, + 599 + ], + [ + 472, + 599 + ], + [ + 773, + 599 + ], + [ + 1078, + 599 + ], + [ + 168, + 886 + ], + [ + 473, + 886 + ], + [ + 770, + 886 + ], + [ + 1074, + 886 + ], + [ + 168, + 1154 + ], + [ + 474, + 1154 + ], + [ + 771, + 1154 + ], + [ + 1078, + 1154 + ] + ], + "contacts": [ + [ + 270, + 239 + ], + [ + 568, + 239 + ], + [ + 852, + 242 + ], + [ + 1104, + 249 + ], + [ + 284, + 544 + ], + [ + 566, + 541 + ], + [ + 875, + 550 + ], + [ + 1182, + 549 + ], + [ + 270, + 830 + ], + [ + 571, + 825 + ], + [ + 856, + 824 + ], + [ + 1184, + 812 + ], + [ + 268, + 1091 + ], + [ + 568, + 1095 + ], + [ + 872, + 1096 + ], + [ + 1184, + 1096 + ] + ] + }, + "guardian": { + "source": "assets/guardian-beat.png", + "scale": 0.7, + "rects": [ + [ + 0, + 0, + 326, + 326 + ], + [ + 326, + 0, + 311, + 326 + ], + [ + 637, + 0, + 311, + 326 + ], + [ + 948, + 0, + 306, + 326 + ], + [ + 0, + 326, + 326, + 315 + ], + [ + 326, + 326, + 311, + 315 + ], + [ + 637, + 326, + 311, + 315 + ], + [ + 948, + 326, + 306, + 315 + ], + [ + 0, + 641, + 326, + 307 + ], + [ + 326, + 641, + 311, + 307 + ], + [ + 637, + 641, + 311, + 307 + ], + [ + 948, + 641, + 306, + 307 + ], + [ + 0, + 948, + 326, + 306 + ], + [ + 326, + 948, + 311, + 306 + ], + [ + 637, + 948, + 311, + 306 + ], + [ + 948, + 948, + 306, + 306 + ] + ], + "ground": [ + [ + 158, + 283 + ], + [ + 476, + 283 + ], + [ + 788, + 283 + ], + [ + 1103, + 283 + ], + [ + 163, + 598 + ], + [ + 479, + 598 + ], + [ + 793, + 598 + ], + [ + 1101, + 598 + ], + [ + 170, + 897 + ], + [ + 480, + 897 + ], + [ + 792, + 897 + ], + [ + 1106, + 897 + ], + [ + 161, + 1183 + ], + [ + 476, + 1183 + ], + [ + 789, + 1183 + ], + [ + 1102, + 1183 + ] + ], + "contacts": [ + [ + 114, + 185 + ], + [ + 429, + 185 + ], + [ + 740, + 195 + ], + [ + 1056, + 185 + ], + [ + 112, + 501 + ], + [ + 429, + 500 + ], + [ + 740, + 494 + ], + [ + 1057, + 494 + ], + [ + 115, + 791 + ], + [ + 432, + 794 + ], + [ + 756, + 809 + ], + [ + 1059, + 796 + ], + [ + 114, + 1087 + ], + [ + 430, + 1087 + ], + [ + 744, + 1097 + ], + [ + 1055, + 1087 + ] + ] + }, + "leftHand": { + "source": "assets/typing-hands.png", + "scale": 0.15, + "rects": [ + [ + 0, + 0, + 444, + 440 + ], + [ + 444, + 0, + 444, + 440 + ], + [ + 888, + 0, + 444, + 440 + ], + [ + 1332, + 440, + 442, + 447 + ] + ], + "ground": [ + [ + 208, + 371 + ], + [ + 651, + 371 + ], + [ + 1105, + 371 + ], + [ + 1548, + 790 + ] + ] + }, + "rightHand": { + "source": "assets/typing-hands.png", + "scale": 0.15, + "rects": [ + [ + 0, + 440, + 444, + 447 + ], + [ + 444, + 440, + 444, + 447 + ], + [ + 888, + 440, + 444, + 447 + ], + [ + 1332, + 0, + 442, + 440 + ] + ], + "ground": [ + [ + 240, + 790 + ], + [ + 687, + 790 + ], + [ + 1118, + 790 + ], + [ + 1566, + 371 + ] + ] + } +} diff --git a/docs/concepts/zelda-guardian/guardian-beat-prompt.txt b/docs/concepts/zelda-guardian/guardian-beat-prompt.txt new file mode 100644 index 0000000..b6b1370 --- /dev/null +++ b/docs/concepts/zelda-guardian/guardian-beat-prompt.txt @@ -0,0 +1,26 @@ +Tool: built-in image_gen; transparent_background=true + +Production animation sprite atlas for the RIGHT character in the approved Zelda keyboard skin reference. ONLY the ancient Guardian Stalker, no Link, no keyboard, no screen. Genuine transparent background. Square sprite sheet, exactly FOUR columns and FOUR rows = SIXTEEN frames in reading order. + +CRITICAL SPACING: each character must occupy only the CENTRAL 60% of its square cell in width and 60% in height. Leave HUGE empty transparent gutters, about 20% of a cell on every side. Smaller characters with ample whitespace, never enlarge to fill page. No frames touch each other or canvas edges. The 16 virtual cells must be perfectly equally sized. Fixed local ground baseline at 75% cell height, character horizontally centered at 50%. A small gray flat ground shadow directly under feet provides fixed registration marker. Same scale and camera in ALL frames. + +Character match reference: cute squat ancient six-legged spider robot, tan bronze/stone domed body, carved swirls, dark outlines, pink-magenta energy lines, one central blue-magenta eye mounted on its LEFT-facing side, no horns or stalks. Faces LEFT toward Link. Three-quarter side view, six articulated legs. Keep the eye center at approximately local x=35%, y=45% of cell; keep that exact eye location through frames except tiny recoil. Body motion must be drawn as articulating legs, bending joints, subtle weighted compression, moving ancient plates, rather than rigid sticker movement. Bold organic black outline and flat gentle cel colors consistent with approved concept; no photoreal detail. No laser beam: an external beam will be rendered from the eye center. + +Frames in reading order: +1 quiet idle stance, subdued magenta eye. +2 tiny anticipation, legs start brace. +3 slight crouch, eye glow starts. +4 plates tense and eye brighter. +5 deeper brace, faint magenta eye ring. +6 eye ring grows brighter, legs firmly planted. +7 tense charged pose, small cyan core. +8 fully charged, bright concentrated eye, body compressed. +9 instantaneous FIRE: bright white-cyan eye core, tiny short starburst confined to eye, NO beam, legs push back subtly. +10 recoil through articulated legs, eye glow remains. +11 recoil deeper, front legs absorb force, eye pink. +12 easing recoil, less glow. +13 body recovers and legs extend. +14 near idle, faint eye glow. +15 settling into same neutral pose as frame1. +16 EXACT same neutral pose as frame1. +Draw 16 genuinely sequential animation frames. Never random poses, no walking or rotating, no floating body, no duplicated eyes. No text, numbers, borders, grid lines or background. Guardian should look wider than tall within the central 60% region. diff --git a/docs/concepts/zelda-guardian/link-actions-draft.png b/docs/concepts/zelda-guardian/link-actions-draft.png new file mode 100644 index 0000000..0b894ff Binary files /dev/null and b/docs/concepts/zelda-guardian/link-actions-draft.png differ diff --git a/docs/concepts/zelda-guardian/link-actions-prompt.txt b/docs/concepts/zelda-guardian/link-actions-prompt.txt new file mode 100644 index 0000000..afa7d0f --- /dev/null +++ b/docs/concepts/zelda-guardian/link-actions-prompt.txt @@ -0,0 +1,6 @@ +Tool: built-in image_gen; transparent_background=true + +Edit the input Link 16-frame sprite atlas for production sprite extraction. Preserve every pose and the EXACT 4x4 order and character style, but redraw the sheet with MUCH MORE transparent whitespace. + +MANDATORY: each character INCLUDING entire sword and all slash effects must fit inside ONLY the CENTRAL 60% of each equal square cell. All 16 characters must be SMALLER on the canvas by about 35% relative to input. Transparent gutters of AT LEAST 18% of cell width on each side, top and bottom. No neighboring artwork comes remotely close to each other. Entire canvas transparent. Exactly four evenly spaced columns and four evenly spaced rows. No captions, no numbers, no grid. Each row's shadow/feet baseline is at the same 75% local cell height. Shadow centers exactly at each cell's horizontal center. All characters have identical body scale. This is not a tightly packed contact sheet, it is a SPARSE sprite atlas with a lot of negative space. +Keep the full 16 poses: first 8 consecutive frames sword wind-up, slash, follow-through and recovery; last 8 shield raise, brace, parry, recoil and recovery. First, eighth, ninth and sixteenth poses all have the same neutral rest pose and scale. Link faces RIGHT in all poses, blue tunic, blue Hylian shield, brown boots, silver sword, blond hair. Preserve shield visibility during sword swing including frame4. Draw flat simple warm gray oval shadows in all frames, fixed at local x50%, baseline75%; shadow is the fixed registration anchor. Small cyan parry glint is allowed. Include every sword tip and effect without clipping. Keep poses and drawn motion; do not replace them with duplicates. diff --git a/docs/concepts/zelda-guardian/prompt-v1.txt b/docs/concepts/zelda-guardian/prompt-v1.txt new file mode 100644 index 0000000..bcd7a8d --- /dev/null +++ b/docs/concepts/zelda-guardian/prompt-v1.txt @@ -0,0 +1,10 @@ +Generated using built-in image_gen. + +Use case: stylized-concept +Asset type: first visual concept for a Miucs desktop keyboard-and-music reactive skin. +Primary request: Zelda themed desktop companion skin. Link is on the LEFT, facing right, holding a Hylian shield and swinging the Master Sword in response to keyboard input. An ancient Guardian Stalker is on the RIGHT, facing left, firing a laser at Link on the music beat. +Composition: one polished cohesive horizontal concept illustration, landscape 3:2 canvas, generous whitespace, compact entire scene visible. A low-profile miniature keyboard occupies the lower central part, drawn with fine hand-inked outlines and subtly ancient Sheikah details. Link stands just above and to the left of the keyboard; Guardian stands above and to its right. Their feet share the same baseline. Keep a clearly visible open gap between the two characters, with the laser crossing horizontally above the keyboard. The complete floating skin should fill about 80% of canvas width with ample clean margins, readable as a desktop widget. +Characters: charming chibi Link with blond hair, pointed ears, blue Champion's Tunic, brown boots, unmistakable blue Hylian shield with red crest and gold Triforce, silver Master Sword. His shield faces the incoming laser, while his sword is raised in an energetic slash with a short restrained blue-white swoosh. Guardian is the recognizable Zelda Breath of the Wild ancient spiderlike Guardian, rounded weathered stone-bronze body with ornate ancient carved motifs, articulated curved mechanical legs, a single glowing magenta-blue eye. Cute yet formidable, somewhat larger than Link, not a humanoid robot. +Action and input visualization: show a single slender luminous pink-red laser with brilliant white core traveling from Guardian's eye on the right to Link's raised shield on the left, ending in a small crisp blue-white shield impact spark. Keep beam below Link's face and separate from sword. Three keyboard keys have a restrained turquoise pressed-key glow suggesting keyboard control. A few tiny discrete rhythmic light accents close to Guardian suggest beat-triggered firing. No giant explosion. +Style: premium hand-drawn 2D game companion illustration, expressive rounded proportions, clean slightly organic dark outlines, warm parchment/stone keyboard with faint turquoise accents, gentle cel shading, limited soft texture, carefully controlled cyan and magenta effects. Quiet almost-white warm background for the concept preview, subtle grounding shadows only. Character silhouettes and facial details readable at small size. +Constraints: exactly one Link on left and one Guardian on right. Only one main scene. No additional characters, no scenery, no landscape, no application window, no borders, no logos, no title, no prose labels, no annotation panels, no watermark. Keyboard key legends may be tiny simple Latin letters. This is a visual concept, not a screenshot or full-screen game. diff --git a/docs/concepts/zelda-guardian/prompt-v2.txt b/docs/concepts/zelda-guardian/prompt-v2.txt new file mode 100644 index 0000000..2b8f3d3 --- /dev/null +++ b/docs/concepts/zelda-guardian/prompt-v2.txt @@ -0,0 +1,17 @@ +Generated using built-in image_gen, referencing existing runtime-preview.png and skin-concept-v1.png. + +Use case: precise-object-edit +Asset type: revised actual desktop skin concept, based on the existing Miucs runtime layout. +Input image 1: EDIT TARGET and strict structural/layout reference, the existing hand-drawn keyboard skin screenshot. +Input image 2: supporting character/style reference only, the previous Zelda promotional art. Do not reuse its composition. +User correction: the previous concept looked like a product promotional image and did not match the existing keyboard. This revision MUST look like a real skin of the existing application. + +Preserve image 1's exact overall silhouette, composition, screen/keyboard ratio, viewpoint, five-row ANSI key layout, individual key positions and widths, keyboard labels, screen rectangle and two short supporting stems. It is an almost frontal flat 2D illustration with a slightly trapezoidal keyboard, not a perspective product render. The display is the upper 40% and keyboard is the lower 50%; preserve their proportions and fit the whole object on canvas. Keep the same bold organic hand-drawn outlines with little intentional line gaps. Preserve transparent outside background. + +Change only the theme and characters: +1. Remove the three animal characters and bubbles from the upper display. INSIDE that same wide shallow screen, place a small chibi Link at the left end and a small ancient Guardian Stalker at the right end, leaving the middle half largely open for the laser. Both characters must be fully enclosed within the display interior, including sword, feet, and Guardian legs. Link is left facing right, blond pointed ears, blue Champion's Tunic, Hylian shield and Master Sword, sword raised to swing and shield raised toward the right. Guardian right faces left, compact dome-shaped ancient carved stone-bronze machine with a single glowing central eye and six spiderlike articulated legs, recognizable Breath of the Wild Guardian proportions. No tall horns or stalk-like extra eyes. Use simpler, flatter cute sprite-like hand-drawn characters that match image 1's clean readable graphic style; not the elaborate shading of image 2. +2. Show one fine pink-red laser from Guardian's eye across the empty screen center to Link's shield. Tiny cyan parry spark at shield, restrained pale-blue sword slash. These effects remain entirely within the screen. The display backdrop stays simple warm off-white, no scenic landscape. +3. Recolor the existing green frame to muted slate blue/aged stone teal, retain cream ordinary keys, use muted blue-gray modifier keys, add a few subtle cyan ancient Sheikah line motifs on frame edges. Ornament must be minimal and never obstruct key labels. Maintain the same keyboard as input image 1, no redesign of hardware. +4. Replace the two dog paws above the keys with similarly sized tiny stylized brown leather-gloved hands with blue sleeve cuffs matching Link, same positioning and footprint as original paws; retain visible key layout. One or two pressed keys have restrained cyan fill. + +Strict invariants: a single rectangular screen above a full five-row keyboard; identical underlying key arrangement to image 1; no characters standing outside or behind the device; no freestanding hero battle outside the screen; no camera angle change; no floating scene above a miniature keyboard. No title, no extra UI, no explanation text, no logo, no border surrounding the complete image, no mockup background, no cast shadow on a table. Transparent outside the skin. Output one concept image with approximately the same wide aspect ratio as image 1. diff --git a/docs/concepts/zelda-guardian/runtime-idle.png b/docs/concepts/zelda-guardian/runtime-idle.png new file mode 100644 index 0000000..b300493 Binary files /dev/null and b/docs/concepts/zelda-guardian/runtime-idle.png differ diff --git a/docs/concepts/zelda-guardian/runtime-preview.gif b/docs/concepts/zelda-guardian/runtime-preview.gif new file mode 100644 index 0000000..1297f08 Binary files /dev/null and b/docs/concepts/zelda-guardian/runtime-preview.gif differ diff --git a/docs/concepts/zelda-guardian/runtime-preview.png b/docs/concepts/zelda-guardian/runtime-preview.png new file mode 100644 index 0000000..66c759e Binary files /dev/null and b/docs/concepts/zelda-guardian/runtime-preview.png differ diff --git a/docs/concepts/zelda-guardian/runtime-preview.webp b/docs/concepts/zelda-guardian/runtime-preview.webp new file mode 100644 index 0000000..f98dc30 Binary files /dev/null and b/docs/concepts/zelda-guardian/runtime-preview.webp differ diff --git a/docs/concepts/zelda-guardian/runtime-slash.png b/docs/concepts/zelda-guardian/runtime-slash.png new file mode 100644 index 0000000..00e5c72 Binary files /dev/null and b/docs/concepts/zelda-guardian/runtime-slash.png differ diff --git a/docs/concepts/zelda-guardian/skin-concept-v1.png b/docs/concepts/zelda-guardian/skin-concept-v1.png new file mode 100644 index 0000000..54e4a92 Binary files /dev/null and b/docs/concepts/zelda-guardian/skin-concept-v1.png differ diff --git a/docs/concepts/zelda-guardian/skin-concept-v2.png b/docs/concepts/zelda-guardian/skin-concept-v2.png new file mode 100644 index 0000000..5203c32 Binary files /dev/null and b/docs/concepts/zelda-guardian/skin-concept-v2.png differ diff --git a/docs/concepts/zelda-guardian/typing-hands-prompt.txt b/docs/concepts/zelda-guardian/typing-hands-prompt.txt new file mode 100644 index 0000000..dd32d94 --- /dev/null +++ b/docs/concepts/zelda-guardian/typing-hands-prompt.txt @@ -0,0 +1,6 @@ +Tool: built-in image_gen; transparent_background=true + +Create a sparse production sprite atlas of ONLY the two small leather-gloved typing hands in the approved Zelda keyboard concept reference. No Link body, no Guardian, no keyboard, no screen. Genuine transparent background. A wide 2:1 canvas, exactly FOUR columns and TWO rows, EIGHT equally sized square cells. Hands small in the center of each cell, only 50% cell width and height, ample transparent gutters. No grid, no text, no numbers. Match the concept's simple bold black hand-inked outline, warm brown leather gloves, bright blue sleeve cuffs, tiny cream cuff seam, minimal flat shading, chibi rounded fingers. +Top row = LEFT hand, thumb on viewer's right, back of hand visible, fingers point upward and sleeve cuff at bottom. Four sequential typing press poses: 1 relaxed hovering fingers gently curved; 2 wrist bends and fingers extend toward a key; 3 fingers compress/flatten on an invisible key, knuckles compressed; 4 fingers spring back gently toward rest. +Bottom row = RIGHT hand, thumb on viewer's left, mirror orientation, same four sequential poses. +Camera is fixed near overhead; same glove proportions, same size and same cuff position across all frames. Entire motion drawn into frames, not translating the glove. Keep cuff center at local x50%, y70% in every cell. Isolated hands only, no cast shadows, no keycaps. Two anatomical hands with consistent natural fingers, not paws. These will render at 40x40 pixels; prioritize clean silhouette and recognizable blue cuff over fine detail. diff --git a/docs/plugin-previews.md b/docs/plugin-previews.md index be9a35f..e84082f 100644 --- a/docs/plugin-previews.md +++ b/docs/plugin-previews.md @@ -1,4 +1,4 @@ -# 四个桌面伙伴的动态预览 +# 桌面伙伴的动态预览 GIF 使用同一组「音乐与打字」合成输入,正式 Lua worker 与 Skia CPU 绘制器生成;每张循环 12 秒、采样 16 FPS、最长边 640 像素。暖白背景仅用于预览,应用浮窗是透明的。GIF 无声,不是桌面录屏。 @@ -8,5 +8,8 @@ GIF 使用同一组「音乐与打字」合成输入,正式 Lua worker 与 Ski | 麦芬 | [查看](../plugins/muffin-puppies/README.md) | | 牛来 | [查看](../plugins/niulai-keyboard/README.md) | | 平次与和叶 | [查看](../plugins/heiji-kazuha/README.md) | +| 塞尔达 | [查看](../plugins/zelda-guardian/README.md) | 导出于 2026-10-30,随机种子为 42。四个插件使用相同公共输入;尺寸、帧数和时长见[导出清单](assets/plugins/manifest.json)。重新生成的方法见[插件工作台](plugin-lab.md#所有皮肤的统一输入-gif)。 + +塞尔达提供[独立的 9 秒动画预览](concepts/zelda-guardian/runtime-preview.gif),依次演示静止、仅键盘、仅音乐、同时输入和停止;未计入上述四插件的统一输入导出清单。 diff --git a/docs/validation/visualization-appearance-tests.json b/docs/validation/visualization-appearance-tests.json index 2c2498b..7e8b56f 100644 --- a/docs/validation/visualization-appearance-tests.json +++ b/docs/validation/visualization-appearance-tests.json @@ -57,7 +57,7 @@ "passed" : true }, { - "name" : "All four modes inherit transparency while adjustment overlays remain opaque", + "name" : "All five modes inherit transparency while adjustment overlays remain opaque", "passed" : true }, { diff --git a/docs/validation/zelda-guardian.json b/docs/validation/zelda-guardian.json new file mode 100644 index 0000000..4afa1f7 --- /dev/null +++ b/docs/validation/zelda-guardian.json @@ -0,0 +1,16 @@ +{ + "checks": 197, + "nodeCount": 479, + "drawnFrames": { + "link": 16, + "guardian": 16, + "left-paw": 4, + "right-paw": 4 + }, + "scriptP95Ms": 0.044917, + "scriptMeanMs": 0.02650984765502046, + "fixedActorAnchors": true, + "constantPixelScale": true, + "inputs": "synthetic, production Lua worker", + "nativeDesktopAcceptance": false +} diff --git a/plugins/zelda-guardian/ASSETS.md b/plugins/zelda-guardian/ASSETS.md new file mode 100644 index 0000000..43a3fdc --- /dev/null +++ b/plugins/zelda-guardian/ASSETS.md @@ -0,0 +1,11 @@ +# 资源来源 + +- 视觉依据:`docs/concepts/zelda-guardian/skin-concept-v2.png`,用户确认的第二版设计。第一版仅作产品图。 +- `assets/link-actions.png`:内置 imagegen 生成,RGBA,16 帧。前 8 帧挥剑,后 8 帧举盾/盾反。最终留白修正版提示词:`docs/concepts/zelda-guardian/link-actions-prompt.txt`;最初草稿保留在概念目录。 +- `assets/guardian-beat.png`:内置 imagegen 生成,RGBA,16 帧。1 静止,2–8 蓄能,9–12 发射后坐,13–16 复位。提示词:`docs/concepts/zelda-guardian/guardian-beat-prompt.txt`。 +- `assets/typing-hands.png`:内置 imagegen 生成,RGBA,8 帧,两手各四态。模型将第四列左右手画反,注册表交换两格,保留原像素。提示词:`docs/concepts/zelda-guardian/typing-hands-prompt.txt`。 +- `frames.lua` / `docs/concepts/zelda-guardian/frame-registration.json`:由 `tools/plugins/register_zelda_frames.py` 生成的源区域、地面/袖口锚点和眼睛/盾牌落点。脚本只读原始 PNG 元数据,不修改图像。源区域不等大,渲染时使用固定像素缩放和固定世界锚点,避免每帧拉伸和跳位。 +- `assets/NotoSans-Regular.ttf` / `OFL.txt`:沿用现有插件字体及 SIL Open Font License。 +- `keys.lua`、`ink.lua` 和键盘输入处理沿用项目现有实现;新增配色和希卡风格简线纹饰由代码绘制。 + +这是用户要求的非官方塞尔达主题皮肤;林克与守护者为现有 IP 角色,生成素材不表示获得原 IP 的商业授权。 diff --git a/plugins/zelda-guardian/README.md b/plugins/zelda-guardian/README.md new file mode 100644 index 0000000..68b87d4 --- /dev/null +++ b/plugins/zelda-guardian/README.md @@ -0,0 +1,39 @@ +# 塞尔达 + +![塞尔达:林克随按键挥剑举盾,守护者随音乐拍点发射激光](../../docs/concepts/zelda-guardian/runtime-preview.gif) + +Miucs API v1 独立皮肤,按确认的第二版概念图制作。保留现有 800×460 画布、上方显示区和五行 ANSI 键盘。石蓝色边框、奶油白键帽、希卡青蓝纹路;真实键位和文字由运行时绘制。 + +## 动作 + +- 左半区新按键:林克播放 8 帧挥剑(蓄力、出剑、剑光、收剑)。右半区新按键:播放 8 帧举盾/盾反(抬盾、格挡、接触、回弹)。空格交替,两侧同时按下优先举盾。长按重复不重触发;密集输入只保留一个 180ms 内的近期动作,停输后不会排队补播。 +- 左右手套各 4 帧,跟随各自分区最后按下的键,支持多键高亮、修饰键、Caps Lock、按下及松开。保留原键位映射,普通键压下发青光。 +- 守护者 16 帧蓄能/发射/后坐/复位。每个可靠拍点立即发出一束约 130ms 的激光,命中当前盾牌位置。根据最近拍点的 BPM 预演一次蓄能,预测时刻没有收到真实拍点就不发射、不重复循环。 +- 静音和音频断开立即撤掉激光;键盘与音频会话、序号和恢复互不影响。过期输入、队列丢失、系统重复、长时间卡顿及隐藏恢复不补播。守护者不根据音量假造拍点。 + +## 逐帧资源 + +内置 imagegen 生成 40 个绘制帧:林克 16、守护者 16、手套 8。角色关节、身体重心、剑光、蓄能光和后坐都画在图集中。运行时只选帧;角色的地面锚点和像素缩放恒定。`frames.lua` 的裁切位移用于消除模型排版间距差异,不生成动作轨迹。激光直线和碰撞小星芒由 Skia 绘制,随帧中的眼睛和盾牌接触点对齐。 + +生成图像保留少量手绘帧间线条差异。原始 PNG 未重绘或重新采样;每个图集的来源与提示词见 [ASSETS.md](ASSETS.md)。 + +## 安装 + +将整个 `zelda-guardian` 文件夹复制到用户插件目录,确保 `manifest.json` 在第一层: + +- macOS:`~/Library/Application Support/Miucs/Plugins/zelda-guardian/` +- Windows:`%LOCALAPPDATA%\Miucs\Plugins\zelda-guardian\` + +应用菜单选择「重新扫描插件」,再在「可视化」中选择「塞尔达」。源码构建会自动打包所有 `plugins/` 子目录;已内置相同 ID 时不要再安装用户副本,以免重名。 + +## 验证与预览 + +```sh +python3 tools/plugins/register_zelda_frames.py +python3 tools/plugins/validate.py plugins/zelda-guardian +python3 tests/plugins/zelda_replay.py +python3 tools/plugins/zelda_preview.py +sh scripts/test-plugins.sh +``` + +`docs/concepts/zelda-guardian/runtime-preview.webp` 为正式 Lua worker 与 Skia 渲染器输出的 9 秒透明动画,依次包含静止、仅键盘、仅音乐、两者同时输入及停止。GIF 为便于观看使用浅色背景。输入是合成数据,不是桌面录屏;系统权限、真实音频识别和 Windows 实机效果不包含在此回放验证内。 diff --git a/plugins/zelda-guardian/assets/NotoSans-Regular.ttf b/plugins/zelda-guardian/assets/NotoSans-Regular.ttf new file mode 100644 index 0000000..d552209 Binary files /dev/null and b/plugins/zelda-guardian/assets/NotoSans-Regular.ttf differ diff --git a/plugins/zelda-guardian/assets/OFL.txt b/plugins/zelda-guardian/assets/OFL.txt new file mode 100644 index 0000000..c82d72e --- /dev/null +++ b/plugins/zelda-guardian/assets/OFL.txt @@ -0,0 +1,94 @@ +Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts) + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/plugins/zelda-guardian/assets/guardian-beat.png b/plugins/zelda-guardian/assets/guardian-beat.png new file mode 100644 index 0000000..99d3164 Binary files /dev/null and b/plugins/zelda-guardian/assets/guardian-beat.png differ diff --git a/plugins/zelda-guardian/assets/link-actions.png b/plugins/zelda-guardian/assets/link-actions.png new file mode 100644 index 0000000..f113e47 Binary files /dev/null and b/plugins/zelda-guardian/assets/link-actions.png differ diff --git a/plugins/zelda-guardian/assets/typing-hands.png b/plugins/zelda-guardian/assets/typing-hands.png new file mode 100644 index 0000000..39bf037 Binary files /dev/null and b/plugins/zelda-guardian/assets/typing-hands.png differ diff --git a/plugins/zelda-guardian/frames.lua b/plugins/zelda-guardian/frames.lua new file mode 100644 index 0000000..3612f87 --- /dev/null +++ b/plugins/zelda-guardian/frames.lua @@ -0,0 +1,131 @@ +-- Registered original image pixels; regenerate with tools/plugins/register_zelda_frames.py. +return { + link={source='assets/link-actions.png',scale=0.64,rects={ + {0,0,343,337}, + {343,0,308,337}, + {651,0,289,337}, + {940,0,322,337}, + {0,337,343,313}, + {343,337,308,313}, + {651,337,289,313}, + {940,337,322,313}, + {0,650,343,284}, + {343,650,308,284}, + {651,650,289,284}, + {940,650,322,284}, + {0,934,343,312}, + {343,934,308,312}, + {651,934,289,312}, + {940,934,322,312}, + },offsets={ + {-168,-293}, + {-130,-293}, + {-107,-293}, + {-97,-293}, + {-168,-262}, + {-129,-262}, + {-122,-262}, + {-138,-262}, + {-168,-236}, + {-130,-236}, + {-119,-236}, + {-134,-236}, + {-168,-220}, + {-131,-220}, + {-120,-220}, + {-138,-220}, + },anchors={ + {102,-54}, + {95,-54}, + {94,-51}, + {67,-44}, + {116,-55}, + {94,-58}, + {102,-49}, + {104,-50}, + {102,-56}, + {98,-61}, + {86,-62}, + {110,-74}, + {100,-63}, + {94,-59}, + {101,-58}, + {106,-58}, + }}, + guardian={source='assets/guardian-beat.png',scale=0.7,rects={ + {0,0,326,326}, + {326,0,311,326}, + {637,0,311,326}, + {948,0,306,326}, + {0,326,326,315}, + {326,326,311,315}, + {637,326,311,315}, + {948,326,306,315}, + {0,641,326,307}, + {326,641,311,307}, + {637,641,311,307}, + {948,641,306,307}, + {0,948,326,306}, + {326,948,311,306}, + {637,948,311,306}, + {948,948,306,306}, + },offsets={ + {-158,-283}, + {-150,-283}, + {-151,-283}, + {-155,-283}, + {-163,-272}, + {-153,-272}, + {-156,-272}, + {-153,-272}, + {-170,-256}, + {-154,-256}, + {-155,-256}, + {-158,-256}, + {-161,-235}, + {-150,-235}, + {-152,-235}, + {-154,-235}, + },anchors={ + {-44,-98}, + {-47,-98}, + {-48,-88}, + {-47,-98}, + {-51,-97}, + {-50,-98}, + {-53,-104}, + {-44,-104}, + {-55,-106}, + {-48,-103}, + {-36,-88}, + {-47,-101}, + {-47,-96}, + {-46,-96}, + {-45,-86}, + {-47,-96}, + }}, + leftHand={source='assets/typing-hands.png',scale=0.15,rects={ + {0,0,444,440}, + {444,0,444,440}, + {888,0,444,440}, + {1332,440,442,447}, + },offsets={ + {-208,-371}, + {-207,-371}, + {-217,-371}, + {-216,-350}, + },anchors={ + }}, + rightHand={source='assets/typing-hands.png',scale=0.15,rects={ + {0,440,444,447}, + {444,440,444,447}, + {888,440,444,447}, + {1332,0,442,440}, + },offsets={ + {-240,-350}, + {-243,-350}, + {-230,-350}, + {-234,-371}, + },anchors={ + }}, +} diff --git a/plugins/zelda-guardian/ink.lua b/plugins/zelda-guardian/ink.lua new file mode 100644 index 0000000..cbb44a5 --- /dev/null +++ b/plugins/zelda-guardian/ink.lua @@ -0,0 +1,92 @@ +-- Persistent, deterministic geometry: small irregular pen lifts, never frame noise. +local I = {color = '#29272D'} +local function hash(s) + local h = 19 + for n = 1, #s do h = (h * 31 + s:byte(n)) % 65521 end + return h +end +local function path(points, close) + local p = {} + for n, v in ipairs(points) do p[#p+1] = {n == 1 and 'M' or 'L', v[1], v[2]} end + if close then p[#p+1] = {'Z'} end + return p +end +function I.rounded(x, y, w, h, r) + local p = {} + for _, c in ipairs({{x+w-r,y+r,-math.pi/2}, {x+w-r,y+h-r,0}, + {x+r,y+h-r,math.pi/2}, {x+r,y+r,math.pi}}) do + for i = 0, 8 do + local a = c[3] + math.pi/2 * i/8 + p[#p+1] = {c[1]+math.cos(a)*r, c[2]+math.sin(a)*r} + end + end + return p +end +function I.ellipse(x,y,rx,ry) + local p={} + for i=0,63 do local a=i*math.pi/32;p[#p+1]={x+rx*math.cos(a),y+ry*math.sin(a)} end + return p +end +function I.smooth(points) + local out={} + for i=1,#points do + local a,b,c,d=points[(i-2)%#points+1],points[i],points[i%#points+1],points[(i+1)%#points+1] + for step=0,5 do + local t=step/6;local p={} + for axis=1,2 do + p[axis]=.5*((2*b[axis])+(-a[axis]+c[axis])*t+ + (2*a[axis]-5*b[axis]+4*c[axis]-d[axis])*t*t+ + (-a[axis]+3*b[axis]-3*c[axis]+d[axis])*t*t*t) + end + out[#out+1]=p + end + end + return out +end +function I.shape(id, points, fill, width, run, gap, closed) + if closed == nil then closed=true end + width,run,gap = width or 2.7,run or 26,gap or 5 + local nodes={} + if fill then nodes[#nodes+1]={type='path',id=id..'-fill',path=path(points,closed),fill=fill} end + local seed=hash(id) + local drawing=true + local remaining=run*(.6+(seed%17)/24) + local length=0 + local strokes={{},{},{}} + local stroke=1 + local start=true + -- Split a sampled contour by arclength, with independent irregular run/gap lengths. + for edge=1,#points-(closed and 0 or 1) do + local a,b=points[edge],points[edge%#points+1] + local dx,dy=b[1]-a[1],b[2]-a[2] + local d=math.sqrt(dx*dx+dy*dy) + local at=0 + while at1 and '#D8DFEB' or '#FFF9EE' + add({type='rect',id='key-shadow-'..k.key,x=k.x+1,y=k.y+4,width=k.width-2,height=31,radius=5,fill='#546B79'}) + local key=I.box('key-'..k.key,0,0,k.width,30,fill,1.8) + key.x=k.x;key.y=k.y;add(key) + add({type='text',id='label-'..k.key,x=k.x+k.width/2-#k.label*(#k.label>3 and 2.5 or 3),y=k.y+5, + text=k.label,font='assets/NotoSans-Regular.ttf',fontSize=#k.label>3 and 10 or 12,fill=I.color}) + end + -- Reuse simple vector paths for hardware ornament; character art is in atlases. + for i,x in ipairs({160,640}) do + add({type='path',id='rune-top-'..i,path={{'M',x-25,10},{'L',x-8,10},{'M',x+8,10},{'L',x+25,10}},stroke='#8BE3E4',strokeWidth=1.4}) + add({type='ellipse',id='rune-eye-'..i,x=x-3,y=7,width=6,height=6,stroke='#8BE3E4',strokeWidth=1.3}) + end + add({type='path',id='rune-bottom',path={{'M',352,405},{'L',384,405},{'M',416,405},{'L',448,405}, + {'M',400,399},{'L',410,405},{'L',400,411},{'L',390,405},{'Z'}},stroke='#89E2E3',strokeWidth=1.5}) + for _,side in ipairs({'left','right'}) do + local a=A[side..'Hand'] + local r,o=a.rects[1],a.offsets[1] + add({type='group',id=side..'-paw',z=20,children={ + {type='image',id=side..'-paw-art',source=a.source,sourceRect=r, + x=o[1]*a.scale,y=o[2]*a.scale,width=r[3]*a.scale,height=r[4]*a.scale,sampling='linear'} + }}) + end + return nodes +end +function K.reset() + K.state={session=nil,cursor=0,time=0,pressed={},observed={},modHeld={},flash={},lit={},spaceLeft=true} + for _,side in ipairs({'left','right'}) do + local key=side=='left' and 'f' or 'j';local p=K.positions[key] + K.state[side]={key=key,x=p.x+p.width/2,y=p.y+15,down=0,age=10} + end +end +K.reset() +function K.update(frame) + local dt=math.max(0,frame.deltaTime or 0) + local input=frame.keyboard + local session=input and input.sessionID + local changed=session~=K.state.session + if changed then K.reset();K.state.session=session end + local s=K.state;s.time=s.time+dt + local hits={} + local now=frame.now or 0 + local sampled=input and input.sampledAt or -1e9 + local live=input and input.state=='running' and now-sampled>=-.05 and now-sampled<=.4 + local through=input and input.sequence or 0 + for key,expiry in pairs(s.flash) do if expiry<=s.time then s.flash[key]=nil end end + if not live or dt>.5 then + s.flash={};s.pressed={};s.observed={};s.modHeld={};s.caps=false + else + local lost=(input.droppedEventCount or 0)>0 + if lost then s.flash={};s.observed={};s.modHeld={} end + local cursor=s.cursor + local function strike(key) + local side=left[key] and 'left' or 'right' + if key=='space' then side=s.spaceLeft and 'left' or 'right';s.spaceLeft=not s.spaceLeft end + s[side].key=key;s[side].age=0;s.flash[key]=s.time+.12;hits[side]=true + end + for _,e in ipairs(input.events or {}) do + local seq=e.sequence or 0 + if seq>cursor and seq<=through then + cursor=seq + local age=math.max(now,sampled)-(e.receivedAt or -1e9) + local key=e.key + if not lost and K.positions[key] and age>=0 and age<=.25 then + if e.kind=='pressed' and not modifiers[key] and key~='capsLock' then + if not s.observed[key] then strike(key) end;s.observed[key]=true + elseif e.kind=='released' then s.observed[key]=nil;s.flash[key]=nil + elseif e.kind=='modifiersChanged' then + if key=='capsLock' then strike(key) + elseif modifiers[key] then + if e.modifierIsPressed and not s.modHeld[key] then strike(key) end + s.modHeld[key]=e.modifierIsPressed or nil + if e.modifierIsPressed==false then s.flash[key]=nil end + end + end + end + end + end + s.pressed={} + for _,held in ipairs(input.heldKeys or {}) do if K.positions[held.key] then s.pressed[held.key]=true end end + for key in pairs(s.observed) do if not s.pressed[key] then s.observed[key]=nil end end + local bits=input.modifiers or 0 + for key in pairs(s.modHeld) do + if (bits&modifiers[key])~=0 then s.pressed[key]=true else s.modHeld[key]=nil end + end + s.caps=(bits&16)~=0 + end + s.cursor=math.max(s.cursor,through) + for _,k in ipairs(K.keys) do + local lit=not not(s.pressed[k.key] or s.flash[k.key] or (k.key=='capsLock' and s.caps)) + if s.lit[k.key]~=lit then + s.lit[k.key]=lit + miucs.set('key-'..k.key..'-fill',{fill=lit and '#8CE4E8' or (#k.label>1 and '#D8DFEB' or '#FFF9EE')}) + miucs.set('key-'..k.key,{y=k.y+(lit and 3 or 0)}) + miucs.set('label-'..k.key,{y=k.y+(lit and 8 or 5)}) + end + end + for _,side in ipairs({'left','right'}) do + local p=s[side] + if not live then p.key=side=='left' and 'f' or 'j' end + local k=K.positions[p.key] + local down=(s.pressed[p.key] or s.flash[p.key]) and 1 or 0 + p.x=approach(p.x,k.x+k.width/2,dt);p.y=approach(p.y,k.y+15,dt);p.down=approach(p.down,down,dt) + p.age=p.age+dt + local pose=1 + if down==1 then pose=p.age<.045 and 2 or 3 + elseif p.down>.08 then pose=4 end + local a=A[side..'Hand'];local r,o=a.rects[pose],a.offsets[pose] + miucs.set(side..'-paw',{x=p.x,y=p.y+20}) + miucs.set(side..'-paw-art',{sourceRect=r,x=o[1]*a.scale,y=o[2]*a.scale, + width=r[3]*a.scale,height=r[4]*a.scale}) + end + hits.live=not not live;hits.reset=changed or dt>.5 or not live or (input and (input.droppedEventCount or 0)>0) + return hits +end +return K diff --git a/plugins/zelda-guardian/keys.lua b/plugins/zelda-guardian/keys.lua new file mode 100644 index 0000000..92f61a5 --- /dev/null +++ b/plugins/zelda-guardian/keys.lua @@ -0,0 +1,63 @@ +return { + {key="grave",x=42,y=196,width=44,sx=102.66666666666667,sy=36,label="`"}, + {key="digit1",x=90,y=196,width=44,sx=148.0,sy=36,label="1"}, + {key="digit2",x=138,y=196,width=44,sx=193.33333333333331,sy=36,label="2"}, + {key="digit3",x=186,y=196,width=44,sx=238.66666666666666,sy=36,label="3"}, + {key="digit4",x=234,y=196,width=44,sx=284.0,sy=36,label="4"}, + {key="digit5",x=282,y=196,width=44,sx=329.33333333333337,sy=36,label="5"}, + {key="digit6",x=330,y=196,width=44,sx=374.6666666666667,sy=36,label="6"}, + {key="digit7",x=378,y=196,width=44,sx=420.0,sy=36,label="7"}, + {key="digit8",x=426,y=196,width=44,sx=465.3333333333333,sy=36,label="8"}, + {key="digit9",x=474,y=196,width=44,sx=510.6666666666667,sy=36,label="9"}, + {key="digit0",x=522,y=196,width=44,sx=556.0,sy=36,label="0"}, + {key="minus",x=570,y=196,width=44,sx=601.3333333333334,sy=36,label="-"}, + {key="equal",x=618,y=196,width=44,sx=646.6666666666666,sy=36,label="="}, + {key="backspace",x=666,y=196,width=92,sx=714.6666666666666,sy=36,label="Delete"}, + {key="tab",x=37,y=234,width=69,sx=114.0,sy=58,label="Tab"}, + {key="q",x=110,y=234,width=45,sx=170.66666666666669,sy=58,label="Q"}, + {key="w",x=159,y=234,width=45,sx=216.0,sy=58,label="W"}, + {key="e",x=207,y=234,width=45,sx=261.33333333333337,sy=58,label="E"}, + {key="r",x=256,y=234,width=45,sx=306.66666666666663,sy=58,label="R"}, + {key="t",x=305,y=234,width=45,sx=352.0,sy=58,label="T"}, + {key="y",x=353,y=234,width=45,sx=397.3333333333333,sy=58,label="Y"}, + {key="u",x=402,y=234,width=45,sx=442.6666666666667,sy=58,label="U"}, + {key="i",x=451,y=234,width=45,sx=488.0,sy=58,label="I"}, + {key="o",x=499,y=234,width=45,sx=533.3333333333333,sy=58,label="O"}, + {key="p",x=548,y=234,width=45,sx=578.6666666666667,sy=58,label="P"}, + {key="leftBracket",x=597,y=234,width=45,sx=624.0,sy=58,label="["}, + {key="rightBracket",x=645,y=234,width=45,sx=669.3333333333334,sy=58,label="]"}, + {key="backslash",x=694,y=234,width=69,sx=726.0,sy=58,label="\\"}, + {key="capsLock",x=32,y=272,width=82,sx=119.66666666666666,sy=80,label="Caps"}, + {key="a",x=118,y=272,width=45,sx=182.0,sy=80,label="A"}, + {key="s",x=168,y=272,width=45,sx=227.33333333333334,sy=80,label="S"}, + {key="d",x=217,y=272,width=45,sx=272.66666666666663,sy=80,label="D"}, + {key="f",x=266,y=272,width=45,sx=318.0,sy=80,label="F"}, + {key="g",x=316,y=272,width=45,sx=363.3333333333333,sy=80,label="G"}, + {key="h",x=365,y=272,width=45,sx=408.6666666666667,sy=80,label="H"}, + {key="j",x=414,y=272,width=45,sx=454.0,sy=80,label="J"}, + {key="k",x=464,y=272,width=45,sx=499.3333333333333,sy=80,label="K"}, + {key="l",x=513,y=272,width=45,sx=544.6666666666667,sy=80,label="L"}, + {key="semicolon",x=562,y=272,width=45,sx=590.0,sy=80,label=";"}, + {key="quote",x=612,y=272,width=45,sx=635.3333333333334,sy=80,label="'"}, + {key="returnKey",x=661,y=272,width=107,sx=709.0,sy=80,label="Return"}, + {key="leftShift",x=27,y=310,width=108,sx=131.0,sy=102,label="Shift"}, + {key="z",x=140,y=310,width=46,sx=204.66666666666669,sy=102,label="Z"}, + {key="x",x=190,y=310,width=46,sx=250.0,sy=102,label="X"}, + {key="c",x=240,y=310,width=46,sx=295.33333333333337,sy=102,label="C"}, + {key="v",x=290,y=310,width=46,sx=340.6666666666667,sy=102,label="V"}, + {key="b",x=340,y=310,width=46,sx=386.0,sy=102,label="B"}, + {key="n",x=390,y=310,width=46,sx=431.3333333333333,sy=102,label="N"}, + {key="m",x=440,y=310,width=46,sx=476.6666666666667,sy=102,label="M"}, + {key="comma",x=490,y=310,width=46,sx=522.0,sy=102,label=","}, + {key="period",x=540,y=310,width=46,sx=567.3333333333333,sy=102,label="."}, + {key="slash",x=590,y=310,width=46,sx=612.6666666666666,sy=102,label="/"}, + {key="rightShift",x=640,y=310,width=134,sx=697.6666666666666,sy=102,label="Shift"}, + {key="function",x=22,y=348,width=59,sx=108.33333333333333,sy=124,label="Fn"}, + {key="leftControl",x=85,y=348,width=59,sx=165.0,sy=124,label="Ctrl"}, + {key="leftOption",x=149,y=348,width=59,sx=221.66666666666666,sy=124,label="Opt"}, + {key="leftCommand",x=212,y=348,width=59,sx=278.33333333333337,sy=124,label="Cmd"}, + {key="space",x=275,y=348,width=313,sx=448.3333333333333,sy=124,label=""}, + {key="rightCommand",x=592,y=348,width=59,sx=618.3333333333334,sy=124,label="Cmd"}, + {key="rightOption",x=655,y=348,width=59,sx=675.0,sy=124,label="Opt"}, + {key="rightControl",x=719,y=348,width=59,sx=731.6666666666666,sy=124,label="Ctrl"}, +} diff --git a/plugins/zelda-guardian/main.lua b/plugins/zelda-guardian/main.lua new file mode 100644 index 0000000..6e5b8f2 --- /dev/null +++ b/plugins/zelda-guardian/main.lua @@ -0,0 +1,20 @@ +local K=miucs.require('keyboard.lua') +local M=miucs.require('motion.lua') +local S=miucs.require('scene.lua') +local active=false +local state=M.new() +local function reset() + K.reset();state=M.new() + K.update({now=0,deltaTime=0});S.update(state) +end +return { + create=function() miucs.load(S.create());reset() end, + activate=function(viewport) active=viewport.id=='zelda-guardian';reset() end, + update=function(frame) + if active then S.update(M.update(state,frame,K.update(frame))) end + end, + appearanceChanged=function(a) miucs.set('zelda-guardian',{opacity=a.opacity or 1}) end, + resize=function() end, + deactivate=function() active=false;reset() end, + dispose=function() active=false end +} diff --git a/plugins/zelda-guardian/manifest.json b/plugins/zelda-guardian/manifest.json new file mode 100644 index 0000000..0b94586 --- /dev/null +++ b/plugins/zelda-guardian/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "zelda-guardian", + "name": "塞尔达", + "version": "1.0.0", + "api": 1, + "entry": "main.lua", + "inputs": ["audio", "keyboard"], + "layout": {"width": 800, "height": 460, "minimumWidth": 80, "maximumWidth": 1600} +} diff --git a/plugins/zelda-guardian/motion.lua b/plugins/zelda-guardian/motion.lua new file mode 100644 index 0000000..8bbc6e9 --- /dev/null +++ b/plugins/zelda-guardian/motion.lua @@ -0,0 +1,98 @@ +-- Character transforms never animate: motion is drawn into the source frames. +-- Keyboard and audio have independent sessions. All ages use host time. +local M={} +local function clamp(v,a,b) return math.max(a,math.min(b,v)) end +local clips={ + slash={frames={1,2,3,4,5,6,7,8},times={.025,.045,.045,.035,.05,.055,.06,.045}}, + guard={frames={9,10,11,12,13,14,15,16},times={.025,.035,.045,.055,.065,.055,.05,.04}}, +} +for _,c in pairs(clips) do c.duration=0;for _,t in ipairs(c.times) do c.duration=c.duration+t end end +local function actor() return {age=10,frame=1,clip='slash',pending=nil,expires=0} end +local function audioReset(s) + s.shotAge=10;s.guardianFrame=1;s.beam=0;s.charge=0;s.lastBeat=nil + s.audioTime=nil;s.period=nil +end +function M.new() + local s={time=0,link=actor(),audioSession=nil,audioCursor=0,shotCount=0} + audioReset(s);return s +end +local function playLink(s,hits,dt) + local d=s.link + if hits.reset then s.link=actor();d=s.link end + d.age=d.age+dt + -- A simultaneous chord is one guard; no double animation or unbounded queue. + local nextClip=hits.right and 'guard' or (hits.left and 'slash' or nil) + if nextClip then + if d.age>=clips[d.clip].duration then + d.clip=nextClip;d.age=0;d.pending=nil + else d.pending=nextClip;d.expires=s.time+.18 end + elseif d.age>=clips[d.clip].duration then + if d.pending and d.expires>=s.time then d.clip=d.pending;d.age=0 end + d.pending=nil + end + d.frame=1 + local endTime=0 + local c=clips[d.clip] + for i,t in ipairs(c.times) do + endTime=endTime+t + if d.age.5 then + s.link=actor();audioReset(s);s.audioCursor=math.max(s.audioCursor,through) + return s + end + playLink(s,hits,dt) + s.shotAge=s.shotAge+dt + local age=input.frameAge + local live=input.state=='running' and f.sessionID==input.sessionID and age and age>=0 and age<.4 + and a.isSilent==false and type(a.time)=='number' + and now-(f.receivedAt or -1e9)>=-.05 and now-(f.receivedAt or -1e9)<=.45 + local discontinuity=live and s.audioTime and (a.times.audioCursor and seq<=through then + s.audioCursor=seq + if lag>=0 and lag<=.25 and (e.confidence or 0)>=.45 and (e.confidence or 2)<=1 + and (e.bpm or 0)>=50 and (e.bpm or 0)<=200 and (e.strength or -1)>=0 and (e.strength or 2)<=1 then accepted=e end + end + end + if accepted and (input.droppedBeatCount or 0)==0 then + s.shotAge=0;s.shotCount=s.shotCount+1;s.lastBeat=s.time + s.period=60/accepted.bpm;s.strength=accepted.strength + end + else + -- Silence/disconnect removes the laser immediately; keyboard keeps working. + audioReset(s) + end + s.audioCursor=math.max(s.audioCursor,through) + s.beam=0;s.charge=0;s.guardianFrame=1 + -- Fire/recoil frames 9..16 begin on the actual beat, never an extrapolated beat. + if s.shotAge<.28 then + s.guardianFrame=9+math.min(7,math.floor(s.shotAge/.035)) + if s.shotAge<.13 then s.beam=(1-s.shotAge/.13)^.65 end + elseif live and s.lastBeat and s.period then + -- One predicted windup only; a missing beat can never fire or keep looping. + local untilBeat=s.period-(s.time-s.lastBeat) + if untilBeat>=0 and untilBeat<.20 then + s.charge=1-untilBeat/.20 + s.guardianFrame=2+math.min(6,math.floor(s.charge*7)) + end + end + return s +end +return M diff --git a/plugins/zelda-guardian/scene.lua b/plugins/zelda-guardian/scene.lua new file mode 100644 index 0000000..29c5841 --- /dev/null +++ b/plugins/zelda-guardian/scene.lua @@ -0,0 +1,56 @@ +local K=miucs.require('keyboard.lua') +local A=miucs.require('frames.lua') +local S={} +-- Fixed ground anchors and pixel scales. Crop offsets only undo atlas packing. +local actors={ + link={x=140,y=156}, + guardian={x=652,y=156}, +} +local function point(name,index) + local a,v=A[name],actors[name] + local p=a.anchors[index] + return v.x+p[1]*a.scale,v.y+p[2]*a.scale +end +local function pose(a,index) + local r,o=a.rects[index],a.offsets[index] + return {sourceRect=r,x=o[1]*a.scale,y=o[2]*a.scale,width=r[3]*a.scale,height=r[4]*a.scale} +end +function S.create() + local nodes=K.scene() + local stage={} + for _,name in ipairs({'link','guardian'}) do + local a,v=A[name],actors[name] + local art=pose(a,1) + art.type='image';art.id=name..'-art';art.source=a.source;art.sampling='linear' + stage[#stage+1]={type='group',id=name,x=v.x,y=v.y,children={art}} + end + local effects={ + {type='path',id='laser-glow',path={{'M',600,90},{'L',196,90}},stroke='#EC54BB45',strokeWidth=10}, + {type='path',id='laser-edge',pathRef='laser-glow',stroke='#EE58C5',strokeWidth=3.5}, + {type='path',id='laser-core',pathRef='laser-glow',stroke='#FFF6FD',strokeWidth=1.3}, + } + stage[#stage+1]={type='group',id='laser',opacity=0,children=effects} + local rays={} + for i=0,7 do + local angle=i*math.pi/4 + local r=i%2==0 and 10 or 6 + rays[#rays+1]={'M',math.cos(angle)*2,math.sin(angle)*2} + rays[#rays+1]={'L',math.cos(angle)*r,math.sin(angle)*r} + end + stage[#stage+1]={type='group',id='impact',opacity=0,children={ + {type='path',id='impact-aura',path=rays,stroke='#78DFF9',strokeWidth=3.5}, + {type='path',id='impact-core',pathRef='impact-aura',stroke='#FFFFFF',strokeWidth=1.3}, + }} + nodes[#nodes+1]={type='group',id='stage',clip={39,19,722,142},z=5,children=stage} + return {type='group',id='zelda-guardian',width=800,height=460,children=nodes} +end +function S.update(s) + miucs.set('link-art',pose(A.link,s.link.frame)) + miucs.set('guardian-art',pose(A.guardian,s.guardianFrame)) + local gx,gy=point('guardian',s.guardianFrame) + local lx,ly=point('link',s.link.frame) + miucs.set('laser-glow',{path={{'M',gx,gy},{'L',lx,ly}}}) + miucs.set('laser',{opacity=s.beam}) + miucs.set('impact',{x=lx,y=ly,opacity=s.beam}) +end +return S diff --git a/scripts/test-plugins.sh b/scripts/test-plugins.sh index bde3d40..51a3f33 100644 --- a/scripts/test-plugins.sh +++ b/scripts/test-plugins.sh @@ -20,3 +20,6 @@ python3 "$root/tests/plugins/muffin_replay.py" python3 "$root/tools/plugins/validate.py" "$root/plugins/heiji-kazuha" python3 "$root/tests/plugins/heiji_kazuha_replay.py" + +python3 "$root/tools/plugins/validate.py" "$root/plugins/zelda-guardian" +python3 "$root/tests/plugins/zelda_replay.py" diff --git a/tests/platforms/macos/VisualizationAppearanceTests.swift b/tests/platforms/macos/VisualizationAppearanceTests.swift index e447977..e2002fd 100644 --- a/tests/platforms/macos/VisualizationAppearanceTests.swift +++ b/tests/platforms/macos/VisualizationAppearanceTests.swift @@ -79,7 +79,7 @@ enum VisualizationAppearanceTests { let channel = FeatureChannel(now: { 0 }), generation = UUID() channel.reset(sessionID: generation, state: .running) let registry = VisualizationRegistry() - for id in ["pixel-keyboard", "muffin-puppies", "niulai-keyboard", "heiji-kazuha"] { + for id in ["pixel-keyboard", "muffin-puppies", "niulai-keyboard", "heiji-kazuha", "zelda-guardian"] { registry.register(VisualizationDescriptor(id: id, name: id)) { InkProbe(id: id) } } let panel = VisualizerPanel(source: channel, registry: registry, selectedID: "pixel-keyboard", preferences: preferences, persistsFrame: false) @@ -89,12 +89,12 @@ enum VisualizationAppearanceTests { let host = panel.visualizationHost var opacitySurvivesSwitch = true store.setTransparency(70) - for id in ["muffin-puppies", "niulai-keyboard", "heiji-kazuha", "pixel-keyboard"] { + for id in ["muffin-puppies", "niulai-keyboard", "heiji-kazuha", "zelda-guardian", "pixel-keyboard"] { _ = panel.selectVisualization(id: id) opacitySurvivesSwitch = opacitySurvivesSwitch && abs(Double(host.activeView!.alphaValue) - 0.3) < 0.001 && host.subviews.filter { $0 !== host.activeView }.allSatisfy { $0.alphaValue == 1 } } - check("All four modes inherit transparency while adjustment overlays remain opaque", opacitySurvivesSwitch && panel.alphaValue == 1) + check("All five modes inherit transparency while adjustment overlays remain opaque", opacitySurvivesSwitch && panel.alphaValue == 1) host.controller.resume() let session = host.controller.current as! InkProbe, initialFrame = panel.frame store.setTransparency(50) diff --git a/tests/plugins/benchmark.cpp b/tests/plugins/benchmark.cpp index c1065c2..fff78ca 100644 --- a/tests/plugins/benchmark.cpp +++ b/tests/plugins/benchmark.cpp @@ -5,7 +5,7 @@ int main(int argc,char**argv){try{ if(argc<3)return 2; auto frames=miucs::Json::parse(miucs::readFile(argv[2],32*1024*1024)); miucs::Json reports=miucs::Json::array(); - for(auto id:{"pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha"}) { + for(auto id:{"pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha","zelda-guardian"}) { miucs::Runtime runtime(std::filesystem::path(argv[1])/id);miucs::Scene host;host.apply(runtime.packet(0)); host.apply(runtime.handle({{"op","activate"},{"value",{{"id",id}}},{"ack",host.version}})); std::vector update,apply;size_t bytes=0,memory=0; diff --git a/tests/plugins/core_tests.cpp b/tests/plugins/core_tests.cpp index 9360a57..9288eba 100644 --- a/tests/plugins/core_tests.cpp +++ b/tests/plugins/core_tests.cpp @@ -55,7 +55,7 @@ int main(int argc,char**argv){try{ check(unchanged.apply(advance)&&unchanged.version==2,"empty delta can advance version"); } rejects([&]{validateNode({{"id","x"},{"type","path"},{"path",{{"C",1,2}}}});},"path arity"); - const std::set builtinIDs={"pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha"}; + const std::set builtinIDs={"pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha","zelda-guardian"}; auto list=catalog({root.parent_path()});std::set actualIDs; for(auto& item:list["plugins"])actualIDs.insert(item.at("id").get()); check(list["errors"].empty()&&actualIDs==builtinIDs,"independent built-in plugins"); diff --git a/tests/plugins/mac_reload.swift b/tests/plugins/mac_reload.swift index 9fb9d75..896e877 100644 --- a/tests/plugins/mac_reload.swift +++ b/tests/plugins/mac_reload.swift @@ -52,10 +52,10 @@ import AppKit print("PASS CPU surface disposal: 10 reload cycles retain no layers or frame contents") // Catalog scans do not create workers; each selection starts only its own package. let host=PluginHost.shared, registry=PluginVisualizations.registry() - let ids=Set(["pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha"]) + let ids=Set(["pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha","zelda-guardian"]) precondition(ids.isSubset(of:Set(registry.descriptors.map(\.id)))) precondition(host.packages.isEmpty,"Tray must not eagerly launch a plugin") - precondition(registry.descriptors.prefix(4).map(\.id)==["pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha"]) + precondition(registry.descriptors.prefix(5).map(\.id)==["pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha","zelda-guardian"]) precondition(registry.descriptor(id:"pixel-keyboard")?.name=="奶牛猫") let suite="MiucsCuratedPlugins-"+UUID().uuidString let preferences=UserDefaults(suiteName:suite)! diff --git a/tests/plugins/soak.py b/tests/plugins/soak.py index 24434f8..3c40186 100644 --- a/tests/plugins/soak.py +++ b/tests/plugins/soak.py @@ -17,7 +17,7 @@ def rss(): elapsed=time.monotonic()-start # Switch isolated plugins every ten seconds; also replace the worker every five minutes. gen=int(elapsed//300) - mode=['pixel-keyboard','muffin-puppies','niulai-keyboard','heiji-kazuha'][int(elapsed//10)%4] + mode=['pixel-keyboard','muffin-puppies','niulai-keyboard','heiji-kazuha','zelda-guardian'][int(elapsed//10)%5] if gen!=generation or mode!=active: if surface:b.lib.miucs_surface_destroy(surface);surface=None if b:b.close() diff --git a/tests/plugins/win_surface.cpp b/tests/plugins/win_surface.cpp index b4e2c80..798b399 100644 --- a/tests/plugins/win_surface.cpp +++ b/tests/plugins/win_surface.cpp @@ -12,7 +12,7 @@ int main(int argc,char**argv){ try { auto post=[&](const miucs::Json& m){auto b=miucs::encode(m);if(!miucs_plugin_post(plugin,b.data(),b.size()))throw miucs::Error("post failed");}; auto fixture=miucs::Json::parse(miucs::readFile(argv[3],32*1024*1024)); - for(auto id:{"pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha"}) { + for(auto id:{"pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha","zelda-guardian"}) { auto folder=(std::filesystem::path(argv[2])/id).string(); plugin=miucs_plugin_open(argv[1],folder.c_str());if(!plugin)throw miucs::Error("Cannot open worker"); post({{"op","activate"},{"value",{{"id",id},{"width",800},{"height",460},{"scale",1}}}}); diff --git a/tests/plugins/zelda_replay.py b/tests/plugins/zelda_replay.py new file mode 100644 index 0000000..988236e --- /dev/null +++ b/tests/plugins/zelda_replay.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Exercise the real Lua worker: input isolation, frame registration and lifecycle.""" +import copy +import json +from pathlib import Path +import statistics +import subprocess +import sys + +ROOT=Path(__file__).resolve().parents[2] +sys.path.insert(0,str(ROOT/'tools/plugins')) +from wire import send,receive +from preview import frame + +worker=ROOT/('build/plugins/Release/miucs-plugin-worker.exe' if sys.platform=='win32' else 'build/plugins/miucs-plugin-worker') +checks=0 +def check(value,message): + global checks + checks+=1 + if not value: raise AssertionError(message) + +class Harness: + def __init__(self): + self.proc=subprocess.Popen([str(worker),str(ROOT/'plugins/zelda-guardian')],stdin=subprocess.PIPE,stdout=subprocess.PIPE) + self.scene={};self.version=0;self.index=0;self.timings=[] + self.seen={name:set() for name in ('link','guardian','left-paw','right-paw')} + self.read() + self.rest=tuple(self.scene['link-art']['sourceRect']) + self.fixed={name:copy.deepcopy(self.scene[name]) for name in ('link','guardian')} + self.activate() + def read(self): + r=receive(self.proc.stdout) + if not r.get('ok'): raise RuntimeError(r.get('error','worker failed')) + p=r['scene'] + if p['full']: self.scene={} + for n in p['nodes']: self.scene.setdefault(n['id'],{}).update(n) + self.version=p['version'] + if 'updateMs' in r: self.timings.append(r['updateMs']) + for name in self.seen: self.seen[name].add(tuple(self.scene[name+'-art']['sourceRect'])) + if hasattr(self,'fixed'): + for name,expected in self.fixed.items(): + assert self.scene[name]==expected, 'Actor world anchor changed' + art=self.scene[name+'-art'];rect=art['sourceRect'] + scale=.64 if name=='link' else .70 + assert abs(art['width']/rect[2]-scale)<1e-8 and abs(art['height']/rect[3]-scale)<1e-8, 'Frame art stretched' + def post(self,op,value=None): + send(self.proc.stdin,{'op':op,'value':value or {},'ack':self.version});self.read() + def activate(self): self.post('activate',{'id':'zelda-guardian','width':800,'height':460,'scale':1}) + def sample(self,key=None,kind='pressed',held=(),beat=False,audio=True,**overrides): + f=frame(self.index);self.index+=1 + k=f['keyboard'];k.update(events=[],heldKeys=[{'key':key,'duration':.1} for key in held],sampledAt=f['now']+.002,pressesPerSecond=0) + if key: k['events']=[dict(sequence=k['sequence'],receivedAt=f['now']+.002,kind=kind,key=key,modifiers=0)] + a=f['audio'];a['onsets']=[];a['frame']['receivedAt']=f['now']+.001 + a['frame']['audio']['time']+=5000 + a['beats']=[dict(sequence=a['frame']['sequence'],receivedAt=f['now']+.001,time=f['now']+5000,bpm=120,confidence=.9,strength=.8)] if beat else [] + if not audio: del f['audio'] + f.update(overrides);return f + def tick(self,**kw): self.post('update',self.sample(**kw)) + def advance(self,n=60,**kw): + for _ in range(n): self.tick(**kw) + def moving(self): return tuple(self.scene['link-art']['sourceRect'])!=self.rest + def laser(self): return self.scene['laser'].get('opacity',0)>0 + def close(self): + if self.proc.poll() is None: self.proc.kill() + self.proc.wait(timeout=3) + +h=Harness() +try: + nodes=len(h.scene);ink=copy.deepcopy(h.scene['monitor-ink-1']['path']) + h.advance(120,audio=False) + check(not h.moving() and not h.laser(),'silence and idle invent no actions') + for key in ('a','j','space','returnKey','digit1'): + h.activate();h.tick(key=key,audio=False);h.advance(7,audio=False) + check(h.moving(),key+' starts drawn action without audio') + h.advance(80,audio=False);check(not h.moving(),'animation settles without input') + h.activate();h.tick(key='a',held=('a',),audio=False);h.advance(10,held=('a',),audio=False) + slash=tuple(h.scene['link-art']['sourceRect']) + check(h.scene['key-a-fill']['fill']=='#8CE4E8','held physical key lights') + for _ in range(100): h.tick(key='a',kind='repeated',held=('a',),audio=False) + check(not h.moving(),'OS repeat does not restart action') + h.tick(key='a',kind='released',audio=False);h.advance(20,audio=False) + check(h.scene['key-a-fill']['fill']=='#FFF9EE','release clears highlight') + h.activate();h.tick(key='j',audio=False);h.advance(10,audio=False) + check(tuple(h.scene['link-art']['sourceRect'])!=slash,'right keys select shield clip') + h.activate();h.tick(key='space',audio=False);h.advance(10,audio=False) + first=tuple(h.scene['link-art']['sourceRect']);h.advance(60,audio=False) + h.tick(key='space',audio=False);h.advance(10,audio=False) + check(tuple(h.scene['link-art']['sourceRect'])!=first,'space alternates attack and shield') + for mutate,label in [ + (lambda f:f['keyboard'].update(state='secureInput'),'secure input'), + (lambda f:f['keyboard'].update(sampledAt=f['now']-.5),'stale keyboard'), + (lambda f:f['keyboard'].update(droppedEventCount=3),'dropped key queue'), + (lambda f:f['keyboard']['events'][0].update(receivedAt=f['now']-.5),'old press'), + ]: + h.activate();f=h.sample(key='a',audio=False);mutate(f);h.post('update',f);h.advance(8,audio=False) + check(not h.moving(),label+' cannot trigger') + h.activate();f=h.sample(key='leftShift',audio=False) + f['keyboard']['modifiers']=1;f['keyboard']['events'][0].update(kind='modifiersChanged',modifierIsPressed=True) + h.post('update',f);h.advance(8,audio=False);check(h.moving(),'modifier press is supported') + h.activate();f=h.sample(key='a',audio=False);h.post('update',f);h.advance(60,audio=False) + f['now']=h.sample(audio=False)['now'];f['keyboard']['sampledAt']=f['now'];f['keyboard']['events'][0]['receivedAt']=f['now'] + h.post('update',f);h.advance(8,audio=False);check(not h.moving(),'duplicate keyboard sequence ignored') + h.activate();h.tick(beat=True) + check(h.laser(),'actual beat starts laser immediately') + check(not h.moving(),'audio cannot trigger keyboard character animation') + h.advance(12);check(not h.laser(),'short laser fades') + # The next predicted beat has no event: it must NEVER fire. + for _ in range(150): + h.tick();check(not h.laser(),'predicted beat cannot fire') + for mutate,label in [ + (lambda f:f['audio']['frame']['audio'].update(isSilent=True),'silent audio'), + (lambda f:f['audio']['beats'][0].update(confidence=.2),'low confidence'), + (lambda f:f['audio']['beats'][0].update(bpm=250),'out-of-range tempo'), + (lambda f:f['audio']['beats'][0].update(receivedAt=f['now']-.5),'old beat'), + (lambda f:f['audio'].update(frameAge=.8),'old audio snapshot'), + (lambda f:f['audio'].update(droppedBeatCount=2),'dropped beats'), + (lambda f:f['audio']['frame'].update(sessionID='wrong'),'wrong audio session'), + ]: + h.activate();f=h.sample(beat=True);mutate(f);h.post('update',f) + check(not h.laser(),label+' cannot fire') + h.activate();f=h.sample(beat=True);h.post('update',f);h.advance(20) + f['now']=h.sample()['now'];f['audio']['frame']['receivedAt']=f['now'];f['audio']['beats'][0]['receivedAt']=f['now'] + h.post('update',f);check(not h.laser(),'duplicate beat cannot fire again') + h.activate();h.tick(key='a',beat=True) + f=h.sample();f['audio'].update(state='recovering',frame=None,frameAge=None,beats=[]) + h.post('update',f);check(not h.laser(),'audio loss removes beam') + h.advance(8,audio=False);check(h.moving(),'audio loss leaves sword action running') + f=h.sample(beat=True);f['keyboard']['state']='secureInput';h.post('update',f) + check(h.laser() and not h.moving(),'keyboard loss leaves music independent') + f=h.sample(beat=True);f['audio']['sessionID']='new';f['audio']['frame'].update(sessionID='new',sequence=1);f['audio']['beats'][0]['sequence']=1 + h.post('update',f);check(h.laser(),'new audio session accepts fresh low sequence') + h.tick(key='j',beat=True,deltaTime=2) + check(not h.laser() and not h.moving(),'long stall discards backlog') + h.activate() + for i in range(1800): + key=('a' if i//36%2==0 else 'j') if i%36==0 else None + h.tick(key=key,beat=i%30==0) + check(len(h.scene)==nodes,'30 second replay keeps bounded topology') + check(len(h.seen['link'])==16 and len(h.seen['guardian'])==16,'all 32 character frames play') + check(all(len(h.seen[name])==4 for name in ('left-paw','right-paw')),'all eight hand frames play') + # Fast input retains at most one recent action, and must settle shortly after stop. + for i in range(120): h.tick(key='a' if i%2 else 'j',audio=False) + h.advance(60,audio=False);check(not h.moving(),'rapid typing cannot accumulate an animation backlog') + check(h.scene['monitor-ink-1']['path']==ink,'hand-drawn keyboard contour remains stable') + h.post('appearanceChanged',{'opacity':.4,'ink':'#000000'}) + check(h.scene['zelda-guardian']['opacity']==.4,'global opacity retained') + h.post('resize',{'width':400,'height':230,'scale':2}) + check(len(h.scene)==nodes,'resize preserves scene') + h.post('deactivate');before=copy.deepcopy(h.scene);h.tick(key='a',beat=True) + check(h.scene==before,'inactive plugin ignores input') + h.activate();h.advance(60,audio=False);check(not h.laser() and not h.moving(),'reactivation resets') + h.post('dispose') + report=dict(checks=checks,nodeCount=nodes,drawnFrames={k:len(v) for k,v in h.seen.items()}, + scriptP95Ms=sorted(h.timings)[int(len(h.timings)*.95)],scriptMeanMs=statistics.mean(h.timings), + fixedActorAnchors=True,constantPixelScale=True,inputs='synthetic, production Lua worker',nativeDesktopAcceptance=False) + out=ROOT/'docs/validation/zelda-guardian.json';out.write_text(json.dumps(report,indent=2)+'\n') + print(json.dumps(report,indent=2)) +finally: h.close() diff --git a/tools/plugins/register_zelda_frames.py b/tools/plugins/register_zelda_frames.py new file mode 100644 index 0000000..c2f27ab --- /dev/null +++ b/tools/plugins/register_zelda_frames.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Register original model-drawn atlases, without resampling or rewriting pixels. + +The model's rows are not a mechanical grid. Each source region is translated +relative to its painted ground/cuff anchor, at ONE constant pixels-to-world +scale per character. This compensates sheet packing, never invents body motion. +""" +import json +from pathlib import Path +from PIL import Image + +ROOT = Path(__file__).resolve().parents[2] +PACKAGE = ROOT / 'plugins/zelda-guardian' + + +def region_grid(xs, ys): + return [[xs[c], ys[r], xs[c+1]-xs[c], ys[r+1]-ys[r]] + for r in range(len(ys)-1) for c in range(len(xs)-1)] + + +link_ground = list(zip( + [168,473,758,1037,168,472,773,1078,168,473,770,1074,168,474,771,1078], + [293]*4 + [599]*4 + [886]*4 + [1154]*4)) +link_shield = [ + [270,239],[568,239],[852,242],[1104,249], + [284,544],[566,541],[875,550],[1182,549], + [270,830],[571,825],[856,824],[1184,812], + [268,1091],[568,1095],[872,1096],[1184,1096], +] +guardian_ground = list(zip( + [158,476,788,1103,163,479,793,1101,170,480,792,1106,161,476,789,1102], + [283]*4 + [598]*4 + [897]*4 + [1183]*4)) +guardian_eye = [ + [114,185],[429,185],[740,195],[1056,185], + [112,501],[429,500],[740,494],[1057,494], + [115,791],[432,794],[756,809],[1059,796], + [114,1087],[430,1087],[744,1097],[1055,1087], +] +data = { + 'link': dict(source='assets/link-actions.png', scale=.64, + rects=region_grid([0,343,651,940,1262],[0,337,650,934,1246]), + ground=link_ground, contacts=link_shield), + 'guardian': dict(source='assets/guardian-beat.png', scale=.70, + rects=region_grid([0,326,637,948,1254],[0,326,641,948,1254]), + ground=guardian_ground, contacts=guardian_eye), +} +# The model switched handedness in the last column; exchange those two cells. +# These source regions keep the same cuff anchor and constant image scale. +hand_regions = region_grid([0,444,888,1332,1774],[0,440,887]) +hand_ground = [[208,371],[651,371],[1105,371],[1566,371], + [240,790],[687,790],[1118,790],[1548,790]] +for side, indices in [('leftHand',[0,1,2,7]),('rightHand',[4,5,6,3])]: + data[side] = dict(source='assets/typing-hands.png', scale=.15, + rects=[hand_regions[i] for i in indices], + ground=[hand_ground[i] for i in indices]) + +lines = ['-- Registered original image pixels; regenerate with tools/plugins/register_zelda_frames.py.', 'return {'] +for name, entry in data.items(): + im = Image.open(PACKAGE / entry['source']) + assert im.mode == 'RGBA' and im.getextrema()[3][0] == 0 + lines += [f" {name}={{source='{entry['source']}',scale={entry['scale']},rects={{"] + for x,y,w,h in entry['rects']: + assert x>=0 and y>=0 and x+w<=im.width and y+h<=im.height, (name, im.size, [x,y,w,h]) + lines.append(' {'+','.join(map(str,[x,y,w,h]))+'},') + lines.append(' },offsets={') + for (x,y,w,h),(gx,gy) in zip(entry['rects'],entry['ground']): + lines.append(f' {{{x-gx},{y-gy}}},') + lines.append(' },anchors={') + for (cx,cy),(gx,gy) in zip(entry.get('contacts',[]),entry['ground']): + lines.append(f' {{{cx-gx},{cy-gy}}},') + lines.append(' }},') +lines.append('}') +(PACKAGE/'frames.lua').write_text('\n'.join(lines)+'\n') +(ROOT/'docs/concepts/zelda-guardian/frame-registration.json').write_text(json.dumps(data,indent=2)+'\n') +print('Registered 40 drawn frames: 16 Link, 16 Guardian, 8 hands. Original PNGs unchanged.') diff --git a/tools/plugins/zelda_preview.py b/tools/plugins/zelda_preview.py new file mode 100644 index 0000000..382c9c7 --- /dev/null +++ b/tools/plugins/zelda_preview.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Render nine seconds of the production skin, using synthetic input only.""" +import argparse +from pathlib import Path +from PIL import Image +from preview import Bridge,ROOT,frame + +p=argparse.ArgumentParser() +p.add_argument('--output',type=Path,default=ROOT/'docs/concepts/zelda-guardian') +args=p.parse_args();args.output.mkdir(parents=True,exist_ok=True) +b=Bridge(ROOT/'plugins/zelda-guardian') +frames=[] + +def render(width=800,height=460): + raw=b.render('zelda-guardian',width,height) + premultiplied=Image.frombytes('RGBA',(width,height),raw,'raw','BGRA') + return Image.frombytes('RGBa',(width,height),premultiplied.tobytes()).convert('RGBA') + +try: + b.post('activate',{'id':'zelda-guardian','width':800,'height':460,'scale':1}) + for i in range(540): + f=frame(i) + audio_active=180<=i<480 + typing=60<=i<180 or 300<=i<480 + f['audio']['beats']=f['audio']['beats'] if audio_active else [] + f['audio']['onsets']=[] + f['audio']['frame']['audio']['isSilent']=not audio_active + k=f['keyboard'];k.update(events=[],heldKeys=[],pressesPerSecond=0) + if typing: + key=['a','j','r','u','f','l','space','h'][(i//36)%8] + phase=i%36 + if phase<8: k['heldKeys']=[{'key':key,'duration':phase/60}] + if phase in (0,8): + k['events']=[dict(sequence=k['sequence'],receivedAt=f['now'],kind='pressed' if phase==0 else 'released',key=key,modifiers=0)] + b.post('update',f) + if i%2==0: frames.append(render()) + if i in (30,151,363): + name={30:'runtime-idle.png',151:'runtime-slash.png',363:'runtime-preview.png'}[i] + render(1600,920).save(args.output/name) + durations=[33,33,34]*(len(frames)//3) + frames[0].save(args.output/'runtime-preview.webp',save_all=True,append_images=frames[1:],duration=durations,loop=0,lossless=True) + # GIF on warm white is a widely supported preview; the plugin and WebP retain alpha. + gif=[] + for im in frames[::2]: + canvas=Image.new('RGBA',im.size,'#FFFDF8');canvas.alpha_composite(im) + gif.append(canvas.convert('RGB')) + gif[0].save(args.output/'runtime-preview.gif',save_all=True,append_images=gif[1:],duration=[70,60,70]*(len(gif)//3),loop=0,disposal=2) + print('Exported 9s / 270 sampled frames: real Skia rendering, transparent WebP, GIF, 2x snapshots.') +finally: b.close()