diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index ff06dae..53ac862 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -82,6 +82,10 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'Yuru Camp lifecycle validation failed' } python tests/plugins/camp_replay.py if ($LASTEXITCODE -ne 0) { throw 'Yuru Camp input replay failed' } + python tools/plugins/validate.py plugins/inuyasha + if ($LASTEXITCODE -ne 0) { throw 'Inuyasha lifecycle validation failed' } + python tests/plugins/inuyasha_replay.py + if ($LASTEXITCODE -ne 0) { throw 'Inuyasha 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 f4179b7..f28ca2a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ > 预览使用合成音乐与按键输入,经正式 Lua/Skia 绘制器生成;GIF 无声,不是桌面录屏。 -## 六个桌面伙伴 +## 七个桌面伙伴 | 插件 | 效果 | 详细说明 | | --- | --- | --- | @@ -20,8 +20,9 @@ | 平次与和叶 | 平次随打字挥木剑,和叶随节拍转身 | [平次与和叶](plugins/heiji-kazuha/README.md) | | 塞尔达 | 林克随按键挥剑、举盾,守护者随拍点发射激光 | [塞尔达](plugins/zelda-guardian/README.md) | | 摇曳露营 | 抚子随打字吃面,凛随音乐捧杯轻啜,篝火随拍点轻跳 | [摇曳露营](plugins/yuru-camp/README.md) | +| 犬夜叉 | 打字时犬夜叉逞强,节拍触发戈薇指地、扑地和七宝受惊,全程无对白 | [犬夜叉](plugins/inuyasha/README.md) | -[查看六个插件的动态预览](docs/plugin-previews.md)。首次启动默认选择奶牛猫;之后记住你的选择。每个插件分别保存位置和大小,切换不重启输入采集。 +[查看七个插件的动态预览](docs/plugin-previews.md)。首次启动默认选择奶牛猫;之后记住你的选择。每个插件分别保存位置和大小,切换不重启输入采集。 ## 构建与启动 @@ -42,7 +43,7 @@ open dist/Miucs.app ## 常用操作 -- **可视化**:切换六个桌面伙伴,奶牛猫排在首位。 +- **可视化**:切换七个桌面伙伴,奶牛猫排在首位。 - **显示 / 隐藏画面**:控制浮窗显示。 - **画面大小、不透明度**:等比缩放,调整 10%~100% 不透明度。 - **调整位置与大小**:拖动画面或右下角缩放,完成后恢复鼠标穿透;也可悬停 3 秒点击小锁。 @@ -50,7 +51,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 4418a04..2884d75 100644 --- a/apps/windows/WindowsSmokeTests.cs +++ b/apps/windows/WindowsSmokeTests.cs @@ -26,7 +26,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", "zelda-guardian", "yuru-camp" }) + foreach (string id in new[] { "pixel-keyboard", "muffin-puppies", "niulai-keyboard", "heiji-kazuha", "zelda-guardian", "yuru-camp", "inuyasha" }) { if (!host.Views.ContainsKey(id)) throw new InvalidOperationException("Missing bundled plugin: " + id); using var view = new PluginView(host, id); diff --git a/docs/concepts/inuyasha/backdrop-prompt.txt b/docs/concepts/inuyasha/backdrop-prompt.txt new file mode 100644 index 0000000..13423be --- /dev/null +++ b/docs/concepts/inuyasha/backdrop-prompt.txt @@ -0,0 +1 @@ +Use case: stylized-concept, game UI environment layer. Generate ONLY the scenic background illustration of the upper panel of the reference Inuyasha keyboard concept, with every character, speech bubble, word, keyboard, frame and UI decoration removed. Wide panoramic 3:1 ratio. Warm ivory parchment Japanese watercolor and delicate hand ink. Massive Sacred Tree with braided shimenawa rope on far LEFT edge, soft distant feudal village silhouettes and a small shrine torii toward center-right, low grasses and a cottage at far RIGHT edge. Airy pale ivory negative space across middle and upper center; subdued sage olive foliage, muted warm gray wood, a handful of tiny pink petals, calm daylight. Lower edge an unobtrusive pale earth ground strip continuous horizontally, empty foreground for character sprites. Background very low contrast and pale so bright red/green characters can be readable above it. No people, no animals, no stones in foreground, no swords, no text including 坐下, no border, no keyboard. Flat orthographic 2D wide scene background only, all detail along edges and distant horizon, center empty. diff --git a/docs/concepts/inuyasha/concept-v1.png b/docs/concepts/inuyasha/concept-v1.png new file mode 100644 index 0000000..881cea6 Binary files /dev/null and b/docs/concepts/inuyasha/concept-v1.png differ diff --git a/docs/concepts/inuyasha/frame-registration.json b/docs/concepts/inuyasha/frame-registration.json new file mode 100644 index 0000000..e93e77a --- /dev/null +++ b/docs/concepts/inuyasha/frame-registration.json @@ -0,0 +1,850 @@ +{ + "inuyasha": { + "source": "assets/inuyasha.png", + "scale": 0.43, + "rects": [ + [ + 0, + 0, + 314, + 325 + ], + [ + 314, + 0, + 313, + 325 + ], + [ + 627, + 0, + 313, + 325 + ], + [ + 940, + 0, + 314, + 325 + ], + [ + 0, + 325, + 314, + 315 + ], + [ + 314, + 325, + 313, + 315 + ], + [ + 627, + 325, + 313, + 315 + ], + [ + 940, + 325, + 314, + 315 + ], + [ + 0, + 640, + 317, + 282 + ], + [ + 317, + 640, + 310, + 282 + ], + [ + 627, + 640, + 313, + 282 + ], + [ + 940, + 640, + 314, + 282 + ], + [ + 0, + 922, + 323, + 332 + ], + [ + 323, + 922, + 304, + 332 + ], + [ + 627, + 922, + 313, + 332 + ], + [ + 940, + 922, + 314, + 332 + ] + ], + "offsets": [ + [ + -174, + -309 + ], + [ + -172, + -309 + ], + [ + -174, + -308 + ], + [ + -174, + -308 + ], + [ + -180, + -291 + ], + [ + -160, + -290 + ], + [ + -163, + -290 + ], + [ + -232, + -267 + ], + [ + -192, + -234 + ], + [ + -182, + -238 + ], + [ + -186, + -232 + ], + [ + -178, + -231 + ], + [ + -198, + -275 + ], + [ + -176, + -272 + ], + [ + -214, + -281 + ], + [ + -182, + -289 + ] + ], + "bounds": [ + [ + 47, + 29, + 250, + 309 + ], + [ + 359, + 30, + 560, + 309 + ], + [ + 675, + 32, + 878, + 308 + ], + [ + 966, + 25, + 1198, + 308 + ], + [ + 51, + 339, + 262, + 616 + ], + [ + 340, + 345, + 600, + 615 + ], + [ + 663, + 342, + 895, + 615 + ], + [ + 956, + 374, + 1235, + 592 + ], + [ + 11, + 711, + 315, + 874 + ], + [ + 329, + 689, + 625, + 878 + ], + [ + 632, + 734, + 937, + 872 + ], + [ + 950, + 730, + 1245, + 871 + ], + [ + 26, + 988, + 321, + 1197 + ], + [ + 367, + 948, + 608, + 1194 + ], + [ + 682, + 935, + 924, + 1203 + ], + [ + 993, + 932, + 1199, + 1211 + ] + ], + "contacts": [ + [ + 174, + 309 + ], + [ + 486, + 309 + ], + [ + 801, + 308 + ], + [ + 1114, + 308 + ], + [ + 180, + 616 + ], + [ + 474, + 615 + ], + [ + 790, + 615 + ], + [ + 1172, + 592 + ], + [ + 192, + 874 + ], + [ + 499, + 878 + ], + [ + 813, + 872 + ], + [ + 1118, + 871 + ], + [ + 198, + 1197 + ], + [ + 499, + 1194 + ], + [ + 841, + 1203 + ], + [ + 1122, + 1211 + ] + ] + }, + "kagome": { + "source": "assets/kagome.png", + "scale": 0.4, + "rects": [ + [ + 0, + 0, + 384, + 334 + ], + [ + 384, + 0, + 384, + 334 + ], + [ + 768, + 0, + 384, + 334 + ], + [ + 1152, + 0, + 384, + 334 + ], + [ + 0, + 334, + 384, + 330 + ], + [ + 384, + 334, + 384, + 330 + ], + [ + 768, + 334, + 384, + 330 + ], + [ + 1152, + 334, + 384, + 330 + ], + [ + 0, + 664, + 387, + 360 + ], + [ + 387, + 664, + 381, + 360 + ], + [ + 768, + 664, + 384, + 360 + ], + [ + 1152, + 664, + 384, + 360 + ] + ], + "offsets": [ + [ + -226, + -328 + ], + [ + -191, + -328 + ], + [ + -178, + -328 + ], + [ + -170, + -328 + ], + [ + -224, + -324 + ], + [ + -214, + -324 + ], + [ + -188, + -324 + ], + [ + -195, + -324 + ], + [ + -235, + -331 + ], + [ + -209, + -332 + ], + [ + -174, + -331 + ], + [ + -184, + -331 + ] + ], + "bounds": [ + [ + 136, + 7, + 363, + 328 + ], + [ + 481, + 4, + 710, + 328 + ], + [ + 854, + 5, + 1089, + 328 + ], + [ + 1232, + 5, + 1459, + 328 + ], + [ + 136, + 338, + 375, + 658 + ], + [ + 476, + 338, + 742, + 658 + ], + [ + 815, + 339, + 1106, + 658 + ], + [ + 1201, + 345, + 1493, + 658 + ], + [ + 103, + 671, + 385, + 995 + ], + [ + 498, + 668, + 737, + 996 + ], + [ + 851, + 667, + 1086, + 995 + ], + [ + 1244, + 666, + 1476, + 995 + ] + ], + "contacts": [ + [ + 226, + 328 + ], + [ + 575, + 328 + ], + [ + 946, + 328 + ], + [ + 1322, + 328 + ], + [ + 224, + 658 + ], + [ + 598, + 658 + ], + [ + 956, + 658 + ], + [ + 1347, + 658 + ], + [ + 235, + 995 + ], + [ + 596, + 996 + ], + [ + 942, + 995 + ], + [ + 1336, + 995 + ] + ] + }, + "shippo": { + "source": "assets/shippo-dust.png", + "scale": 0.25, + "rects": [ + [ + 0, + 0, + 362, + 390 + ], + [ + 362, + 0, + 362, + 390 + ], + [ + 724, + 0, + 357, + 390 + ], + [ + 1081, + 0, + 367, + 390 + ], + [ + 0, + 390, + 362, + 370 + ], + [ + 362, + 390, + 362, + 370 + ], + [ + 724, + 390, + 358, + 370 + ], + [ + 1082, + 390, + 366, + 370 + ] + ], + "offsets": [ + [ + -166, + -378 + ], + [ + -162, + -379 + ], + [ + -155, + -378 + ], + [ + -156, + -378 + ], + [ + -167, + -350 + ], + [ + -162, + -351 + ], + [ + -156, + -350 + ], + [ + -154, + -350 + ] + ], + "bounds": [ + [ + 15, + 120, + 351, + 378 + ], + [ + 372, + 117, + 717, + 379 + ], + [ + 728, + 97, + 1075, + 378 + ], + [ + 1084, + 73, + 1428, + 378 + ], + [ + 15, + 529, + 359, + 740 + ], + [ + 372, + 453, + 718, + 741 + ], + [ + 729, + 480, + 1073, + 740 + ], + [ + 1085, + 475, + 1432, + 740 + ] + ], + "contacts": [ + [ + 166, + 378 + ], + [ + 524, + 379 + ], + [ + 879, + 378 + ], + [ + 1237, + 378 + ], + [ + 167, + 740 + ], + [ + 524, + 741 + ], + [ + 880, + 740 + ], + [ + 1236, + 740 + ] + ] + }, + "dust": { + "source": "assets/shippo-dust.png", + "scale": 0.44, + "rects": [ + [ + 0, + 760, + 362, + 326 + ], + [ + 362, + 760, + 362, + 326 + ], + [ + 724, + 760, + 362, + 326 + ], + [ + 1086, + 760, + 362, + 326 + ] + ], + "offsets": [ + [ + -70, + -228 + ], + [ + -188, + -237 + ], + [ + -176, + -229 + ], + [ + -178, + -209 + ] + ], + "bounds": [ + [ + 37, + 880, + 340, + 988 + ], + [ + 381, + 857, + 720, + 997 + ], + [ + 758, + 864, + 1061, + 989 + ], + [ + 1113, + 886, + 1423, + 969 + ] + ], + "contacts": [ + [ + 70, + 988 + ], + [ + 550, + 997 + ], + [ + 900, + 989 + ], + [ + 1264, + 969 + ] + ] + } +} diff --git a/docs/concepts/inuyasha/inuyasha-prompt.txt b/docs/concepts/inuyasha/inuyasha-prompt.txt new file mode 100644 index 0000000..9aa25c4 --- /dev/null +++ b/docs/concepts/inuyasha/inuyasha-prompt.txt @@ -0,0 +1,2 @@ +Production animation sprite atlas for the Inuyasha keyboard skin in reference. Use reference ONLY for character identity and hand-inked chibi style. Output genuinely transparent RGBA, no background, no text, no speech bubbles, no keyboard, no labels, no grid lines. ONE character Inuyasha repeated in exactly 16 cells in a regular 4 columns x 4 rows square grid, reading left to right top to bottom. All cells equal size, generous transparent gutters. High resolution 2048x2048 if possible. Same front three-quarter view facing slightly right in ALL poses. Full body every cell. Long silver-white hair, white dog ears, bright cinnabar red fire-rat robe and baggy trousers, bare feet, violet prayer-bead necklace with white fangs. Charming thick charcoal outlines and ivory highlights, gentle cel shading. Exactly the same head size and bodily scale across all frames; lying poses expand horizontally rather than becoming larger. Ground baseline at same height within every cell; standing character fits comfortably. No sword, no effects, no dust: separate layers later. +Action: playful boast to magical downward pull to harmless faceplant then recovery. Frame 1 upright neutral arms folded, ears alert, confident; 2 blink neutral, same pose; 3 shifts weight elbows rise; 4 boasts chin up arms folded proudly; 5 annoyed glance to right shoulders raised; 6 necklace pulls downward, eyes wide and torso bends; 7 bends forward sharply arms unfolding reaching ground hair flies up; 8 halfway falling diagonal body hands reach forward; 9 faceplant contact, belly on ground, hands and broad sleeves forward, silver hair flops, legs kicked up; 10 comic impact squash torso flat legs highest; 11 prone legs start dropping; 12 prone settled, cheeks embarrassed, one eye visible toward viewer; 13 lifts head and chest onto palms; 14 kneels, head upright, robe on ground; 15 one foot planted rising from kneel; 16 upright arms settling back to frame 1. Draw sixteen genuinely distinct anatomical poses, consistent character identity. Do not duplicate the standing pose for fallen frames. Keep body fully inside each cell with at least 5 percent gutters. This is a clean production spritesheet, absolutely no words including 坐下. diff --git a/docs/concepts/inuyasha/kagome-prompt.txt b/docs/concepts/inuyasha/kagome-prompt.txt new file mode 100644 index 0000000..0dcdc48 --- /dev/null +++ b/docs/concepts/inuyasha/kagome-prompt.txt @@ -0,0 +1 @@ +Production animation sprite sheet, transparent RGBA background. Reference image is character/style reference ONLY. Draw Kagome Higurashi from Inuyasha, cute chibi matching the reference: long black hair, white sailor blouse with dark green collar, red necktie, dark green pleated skirt, white socks and brown loafers. Full body, three-quarter front facing slightly LEFT toward Inuyasha offscreen, same angle and consistent head/body scale all poses. Confident thick warm charcoal ink, ivory highlights, cel-shaded charming premium anime game sprites. Exactly 12 distinct frames arranged 4 columns x 3 rows on a landscape canvas. Equal cells, 8 percent transparent padding inside every cell, all shoes on same ground baseline, same shoe positions across frames, whole body never clipped. No scene, no other character, no text or speech bubbles or punctuation whatsoever. Hand gestures and expressions alone tell the story. Left hand (viewer right) usually on hip, RIGHT arm toward viewer left performs downward pointing gesture. Frames left-to-right then top-to-bottom: 1 neutral standing relaxed with one hand on hip; 2 neutral blink; 3 eyebrows tighten, right elbow lifts; 4 right hand raises to chest, index finger extends; 5 finger raised near shoulder, stern cheeks; 6 hand travels forward down, mouth small open with mock annoyance; 7 finger points decisively at ground to viewer left, elbow almost extended; 8 strongest downward point and puffed cheeks, hair follows motion slightly; 9 holds downward point, mouth now shut; 10 elbow retracts and face softens; 11 hand lowers relaxed with small satisfied smile; 12 fully returned neutral same stance as frame 1. Ensure finger anatomy readable, no letters 坐下, no effect bubbles. Actual drawn pose changes; not duplicate standing illustrations. diff --git a/docs/concepts/inuyasha/keyframes.md b/docs/concepts/inuyasha/keyframes.md new file mode 100644 index 0000000..97d62c2 --- /dev/null +++ b/docs/concepts/inuyasha/keyframes.md @@ -0,0 +1,47 @@ +# 犬夜叉 · 无对白动作皮肤 + +用户选定「坐下!」喜剧方向,并要求画面移除所有对白文字,只用动作表达。最终效果以[实际运行预览](runtime-preview.gif)为准;`concept-v1.png` 仅保存最初造型参考,其中对白已被后续要求取代。 + +## 40 个绘制帧 + +| 图集 | 帧号 | 画面 | +| --- | --- | --- | +| 犬夜叉 | 1–2 | 抱臂待机、眨眼 | +| | 3–5 | 抬肩、抬头逞强、不服气地看右侧 | +| | 6–8 | 念珠下拉、伸手下弯、向前扑落 | +| | 9–12 | 手掌落地、压低身体、脚放下、趴着抬眼 | +| | 13–16 | 双手撑起、跪坐、单脚起身、恢复站立 | +| 戈薇 | 1–2 | 站立、眨眼 | +| | 3–5 | 抬起手肘、伸出食指、蓄势 | +| | 6–9 | 手臂向下、指地、鼓脸、保持手势 | +| | 10–12 | 收回手肘、微笑放下手、回到站姿 | +| 七宝 | 1–2 | 石头后探头、眨眼 | +| | 3–5 | 瞪大眼睛、受惊探起、缩到石头后 | +| | 6–8 | 重新探头、掩嘴偷笑、回到待机 | +| 尘土 | 1–4 | 小尘团、扩张、散开、消失前的细丝 | + +## 输入和时序 + +新键盘按下触发 0.60 秒逞强:3 → 4 → 5 → 16 → 1。只保留一个有效期 160ms 的近期后续动作;重复键、输入摘要不会触发动作。 + +音乐联动从第一枚合格拍点启动,随后每四枚合格拍点尝试启动;要求置信度至少 0.6、强度至少 0.35、50–200 BPM 且事件新鲜。动作未完成时丢弃触发,不排队。 + +| 联动时间 | 犬夜叉 | 戈薇 | 七宝/尘土 | +| --- | --- | --- | --- | +| 0.00–0.20s | 站姿、转头 | 抬手准备 | 七宝观察 | +| 0.20–0.48s | 念珠拉下,伸手扑落 | 食指前伸向地面 | 七宝瞪大眼睛 | +| 0.48–0.62s | 落地、压低、双脚落下 | 指地最强姿势 | 七宝惊起;尘土从后景扩散 | +| 0.62–1.20s | 趴伏、抬眼 | 保持后收手 | 躲藏、探头、偷笑;尘土 0.84s 消散 | +| 1.20–1.94s | 双手撑起 → 跪起 → 站起 | 放下手回待机 | 七宝回待机 | + +声音中断后已开始的动作播完;键盘断连只清除键盘驱动的逞强。长于 0.5 秒的更新间隔、隐藏重新激活会清除动作,避免恢复后补播。待机仅使用绘制的眨眼帧。 + +## 实现与检查 + +保持 800×460 画布、原有五行键位与按键事件映射。角色、石头和尘土保持固定世界锚点,所有帧使用统一像素比例;背景等比裁切。无台词气泡,只有真实键帽标签。 + +- `tools/plugins/register_inuyasha_frames.py`:读取 alpha 定位透明间隔与落脚点,保存原始像素。 +- `tests/plugins/inuyasha_replay.py`:在生产 Lua worker 上检查全部 40 帧、动作同步、输入有效性、生命周期、尺寸与锚点。 +- `tools/plugins/inuyasha_preview.py`:合成输入驱动实际渲染器,输出 12 秒 GIF、透明 WebP 和双倍尺寸关键画面。 + +此预览展示真实插件渲染路径,不代表真实音乐拍点质量或 Windows 桌面实测。 diff --git a/docs/concepts/inuyasha/runtime-boasting.png b/docs/concepts/inuyasha/runtime-boasting.png new file mode 100644 index 0000000..22eb023 Binary files /dev/null and b/docs/concepts/inuyasha/runtime-boasting.png differ diff --git a/docs/concepts/inuyasha/runtime-idle.png b/docs/concepts/inuyasha/runtime-idle.png new file mode 100644 index 0000000..2db6263 Binary files /dev/null and b/docs/concepts/inuyasha/runtime-idle.png differ diff --git a/docs/concepts/inuyasha/runtime-pointing.png b/docs/concepts/inuyasha/runtime-pointing.png new file mode 100644 index 0000000..79276d9 Binary files /dev/null and b/docs/concepts/inuyasha/runtime-pointing.png differ diff --git a/docs/concepts/inuyasha/runtime-preview.gif b/docs/concepts/inuyasha/runtime-preview.gif new file mode 100644 index 0000000..2b8225b Binary files /dev/null and b/docs/concepts/inuyasha/runtime-preview.gif differ diff --git a/docs/concepts/inuyasha/runtime-preview.png b/docs/concepts/inuyasha/runtime-preview.png new file mode 100644 index 0000000..825a895 Binary files /dev/null and b/docs/concepts/inuyasha/runtime-preview.png differ diff --git a/docs/concepts/inuyasha/runtime-preview.webp b/docs/concepts/inuyasha/runtime-preview.webp new file mode 100644 index 0000000..77cfa65 Binary files /dev/null and b/docs/concepts/inuyasha/runtime-preview.webp differ diff --git a/docs/concepts/inuyasha/shippo-prompt.txt b/docs/concepts/inuyasha/shippo-prompt.txt new file mode 100644 index 0000000..57f50b2 --- /dev/null +++ b/docs/concepts/inuyasha/shippo-prompt.txt @@ -0,0 +1 @@ +Create a transparent RGBA production sprite atlas with exactly 12 drawings, 4 columns x 3 rows equal square cells, no grid lines. Reference is visual character/style reference only. Premium chibi anime, thick charcoal line art, warm muted cel colors. No background of any kind: fully transparent between sprites, no colored glow or vignette. No text, speech bubbles or symbols. Same scale and baseline inside each cell, generous gutters, never clip artwork. TOP TWO ROWS (8 frames): Shippo from Inuyasha, a small cute fox boy with orange hair tied high by a tiny teal bow, teal kimono, cream fluffy fox tail, peeking behind the SAME small mossy gray rock. The ROCK must be IDENTICAL fixed outline and same size and position in all 8 frames, character moves above it only. Frame1 calm curious head and hands above rock; 2 blink; 3 eyes widen ears/hair lift; 4 startled pops taller above rock, small open mouth; 5 duck hides with only eyes hair and tail above rock; 6 rises shyly hands at rock; 7 amused closed-eye giggle with one hand covering mouth; 8 returns calm pose nearly identical to 1. Shippo must remain recognizable, no animal transformation. BOTTOM ROW: FOUR separate small hand-drawn pale ochre impact-dust puff effects, no character, no rock. Cell9 two tiny puffs either side of EMPTY center; 10 larger two curled dust clouds moving outward; 11 thinning wispy dust spreading farther; 12 final small airy wisps. Keep empty center and never include floor or opaque rectangle. Dust ivory tan muted colors with warm soft brown ink edges, no words. Each asset self-contained on truly transparent background. Do not draw Kagome, Inuyasha, keyboard or speech bubbles. diff --git a/docs/plugin-previews.md b/docs/plugin-previews.md index 72fefdd..f8cbbe7 100644 --- a/docs/plugin-previews.md +++ b/docs/plugin-previews.md @@ -10,9 +10,12 @@ GIF 使用同一组「音乐与打字」合成输入,正式 Lua worker 与 Ski | 平次与和叶 | [查看](../plugins/heiji-kazuha/README.md) | | 塞尔达 | [查看](../plugins/zelda-guardian/README.md) | | 摇曳露营 | [查看](../plugins/yuru-camp/README.md) | +| 犬夜叉 | [查看](../plugins/inuyasha/README.md) | 导出于 2026-10-30,随机种子为 42。四个插件使用相同公共输入;尺寸、帧数和时长见[导出清单](assets/plugins/manifest.json)。重新生成的方法见[插件工作台](plugin-lab.md#所有皮肤的统一输入-gif)。 塞尔达提供[独立的 9 秒动画预览](concepts/zelda-guardian/runtime-preview.gif),依次演示静止、仅键盘、仅音乐、同时输入和停止;未计入上述四插件的统一输入导出清单。 摇曳露营提供[独立的 12 秒动画预览](concepts/yuru-camp/runtime-preview.gif),依次展示抚子吃面、凛轻啜和节拍篝火;同样使用合成输入与实际渲染器。 + +犬夜叉提供[独立的 12 秒动画预览](concepts/inuyasha/runtime-preview.gif),展示无对白的逞强、指地扑倒和七宝反应;使用合成输入与实际渲染器。 diff --git a/docs/validation/inuyasha.json b/docs/validation/inuyasha.json new file mode 100644 index 0000000..8849418 --- /dev/null +++ b/docs/validation/inuyasha.json @@ -0,0 +1,17 @@ +{ + "checks": 481, + "nodeCount": 521, + "drawnFrames": { + "inuyasha": 16, + "kagome": 12, + "shippo": 8, + "dust": 4 + }, + "scriptP95Ms": 0.032375, + "scriptMeanMs": 0.018350857723577235, + "fixedActorAnchors": true, + "constantPixelScale": true, + "noDialogueText": true, + "inputs": "synthetic, production Lua worker", + "nativeDesktopAcceptance": false +} diff --git a/docs/validation/visualization-appearance-tests.json b/docs/validation/visualization-appearance-tests.json index e9051f5..a1f60f8 100644 --- a/docs/validation/visualization-appearance-tests.json +++ b/docs/validation/visualization-appearance-tests.json @@ -57,7 +57,7 @@ "passed" : true }, { - "name" : "All six modes inherit transparency while adjustment overlays remain opaque", + "name" : "All seven modes inherit transparency while adjustment overlays remain opaque", "passed" : true }, { diff --git a/plugins/inuyasha/ASSETS.md b/plugins/inuyasha/ASSETS.md new file mode 100644 index 0000000..1984b38 --- /dev/null +++ b/plugins/inuyasha/ASSETS.md @@ -0,0 +1,14 @@ +# 绘制资源 + +全部角色图集和背景通过内置 imagegen 生成。角色参考用户确认的概念图;用户后续指定去掉对白,因此运行时只包含动作与键帽标签,不包含「坐下!」文字、对白气泡或音效字。 + +| 文件 | 内容 | 最终生成提示词 | +| --- | --- | --- | +| `assets/inuyasha.png` | 1254×1254 RGBA;16 帧逞强、扑地、恢复 | [犬夜叉](../../docs/concepts/inuyasha/inuyasha-prompt.txt) | +| `assets/kagome.png` | 1536×1024 RGBA;12 帧待机和指地 | [戈薇](../../docs/concepts/inuyasha/kagome-prompt.txt) | +| `assets/shippo-dust.png` | 1448×1086 RGBA;8 帧七宝与石头、4 帧尘土 | [七宝与尘土](../../docs/concepts/inuyasha/shippo-prompt.txt) | +| `assets/backdrop.png` | 2172×724;御神木与村落的静态全景 | [背景](../../docs/concepts/inuyasha/backdrop-prompt.txt) | + +[注册工具](../../tools/plugins/register_inuyasha_frames.py)查找各格间的透明间隔和脚底/石头底部接触带,生成 sourceRect、固定像素比例、绘制偏移和[注册记录](../../docs/concepts/inuyasha/frame-registration.json)。所有 PNG 保留生成原像素,不逐帧拉伸、旋转或镜像,不通过插值变形伪造角色动作。背景等比放大后在舞台范围裁切。 + +`keys.lua`、`ink.lua` 和键盘输入消费逻辑来自现有插件;边框与按键为静态程序绘制 UI。Noto Sans 字体沿用 `OFL.txt` 的 SIL Open Font License。犬夜叉、日暮戈薇和七宝为《犬夜叉》的角色,本皮肤为非官方主题创作。 diff --git a/plugins/inuyasha/README.md b/plugins/inuyasha/README.md new file mode 100644 index 0000000..e8d1724 --- /dev/null +++ b/plugins/inuyasha/README.md @@ -0,0 +1,25 @@ +# 犬夜叉 + +「坐下!」喜剧主题,画面只用动作表达,**没有对白文字和气泡**。上方是御神木与村落背景、犬夜叉、戈薇和七宝,下方保留完整五行键盘。 + +![实际运行预览](../../docs/concepts/inuyasha/runtime-preview.gif) + +- 新按键让犬夜叉抬头逞强,键帽泛起珊瑚色。持续按住不重复触发,和弦保持多键反馈。 +- 第一枚可靠且足够强的音乐拍点、随后每四枚合格拍点触发一次指地联动。戈薇抬手指地,犬夜叉被念珠拉下,七宝受惊躲藏再偷笑。 +- 一次联动约 1.94 秒,落地时尘土与七宝反应同步。密集输入不能打断扑地或爬起;忙碌时的拍点不排队。 +- 静音时不产生新动作,已开始的联动自然收尾。待机只偶尔眨眼。隐藏、重新激活或长卡顿后不补播。 +- 深靛蓝框、暖白键帽、左侧朱红和右侧鼠尾草绿修饰键,边框点缀念珠、犬牙及菱纹;空格键有小狗耳轮廓。 + +40 个模型绘制帧:犬夜叉 16、戈薇 12、七宝 8、尘土 4。固定锚点、固定像素比例,动作来自图集原始帧。见[关键帧与时序](../../docs/concepts/inuyasha/keyframes.md)、[资源来源与提示词](ASSETS.md)。 + +## 安装与验证 + +将本目录放入 Miucs 用户插件目录,在可视化菜单中选择「犬夜叉」。插件 ID 为 `inuyasha`,入口 `main.lua`,画布 800×460。 + +```sh +python3 tools/plugins/validate.py plugins/inuyasha +python3 tests/plugins/inuyasha_replay.py +python3 tools/plugins/inuyasha_preview.py +``` + +预览由合成键盘/音频输入驱动实际 Lua worker 和 Skia 渲染器生成,并非真实音乐录屏。键盘和音频使用独立会话与去重游标,拒绝过期、未来、丢失和低置信度事件。 diff --git a/plugins/inuyasha/assets/NotoSans-Regular.ttf b/plugins/inuyasha/assets/NotoSans-Regular.ttf new file mode 100644 index 0000000..d552209 Binary files /dev/null and b/plugins/inuyasha/assets/NotoSans-Regular.ttf differ diff --git a/plugins/inuyasha/assets/OFL.txt b/plugins/inuyasha/assets/OFL.txt new file mode 100644 index 0000000..c82d72e --- /dev/null +++ b/plugins/inuyasha/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/inuyasha/assets/backdrop.png b/plugins/inuyasha/assets/backdrop.png new file mode 100644 index 0000000..14a2a9a Binary files /dev/null and b/plugins/inuyasha/assets/backdrop.png differ diff --git a/plugins/inuyasha/assets/inuyasha.png b/plugins/inuyasha/assets/inuyasha.png new file mode 100644 index 0000000..4b22f1a Binary files /dev/null and b/plugins/inuyasha/assets/inuyasha.png differ diff --git a/plugins/inuyasha/assets/kagome.png b/plugins/inuyasha/assets/kagome.png new file mode 100644 index 0000000..fc45ba1 Binary files /dev/null and b/plugins/inuyasha/assets/kagome.png differ diff --git a/plugins/inuyasha/assets/shippo-dust.png b/plugins/inuyasha/assets/shippo-dust.png new file mode 100644 index 0000000..9f6a510 Binary files /dev/null and b/plugins/inuyasha/assets/shippo-dust.png differ diff --git a/plugins/inuyasha/frames.lua b/plugins/inuyasha/frames.lua new file mode 100644 index 0000000..644c01c --- /dev/null +++ b/plugins/inuyasha/frames.lua @@ -0,0 +1,95 @@ +-- Original pixels, fixed contact registration. Generated by register_inuyasha_frames.py. +return { + inuyasha={source='assets/inuyasha.png',scale=0.43,rects={ + {0,0,314,325}, + {314,0,313,325}, + {627,0,313,325}, + {940,0,314,325}, + {0,325,314,315}, + {314,325,313,315}, + {627,325,313,315}, + {940,325,314,315}, + {0,640,317,282}, + {317,640,310,282}, + {627,640,313,282}, + {940,640,314,282}, + {0,922,323,332}, + {323,922,304,332}, + {627,922,313,332}, + {940,922,314,332}, + },offsets={ + {-174,-309}, + {-172,-309}, + {-174,-308}, + {-174,-308}, + {-180,-291}, + {-160,-290}, + {-163,-290}, + {-232,-267}, + {-192,-234}, + {-182,-238}, + {-186,-232}, + {-178,-231}, + {-198,-275}, + {-176,-272}, + {-214,-281}, + {-182,-289}, + }}, + kagome={source='assets/kagome.png',scale=0.4,rects={ + {0,0,384,334}, + {384,0,384,334}, + {768,0,384,334}, + {1152,0,384,334}, + {0,334,384,330}, + {384,334,384,330}, + {768,334,384,330}, + {1152,334,384,330}, + {0,664,387,360}, + {387,664,381,360}, + {768,664,384,360}, + {1152,664,384,360}, + },offsets={ + {-226,-328}, + {-191,-328}, + {-178,-328}, + {-170,-328}, + {-224,-324}, + {-214,-324}, + {-188,-324}, + {-195,-324}, + {-235,-331}, + {-209,-332}, + {-174,-331}, + {-184,-331}, + }}, + shippo={source='assets/shippo-dust.png',scale=0.25,rects={ + {0,0,362,390}, + {362,0,362,390}, + {724,0,357,390}, + {1081,0,367,390}, + {0,390,362,370}, + {362,390,362,370}, + {724,390,358,370}, + {1082,390,366,370}, + },offsets={ + {-166,-378}, + {-162,-379}, + {-155,-378}, + {-156,-378}, + {-167,-350}, + {-162,-351}, + {-156,-350}, + {-154,-350}, + }}, + dust={source='assets/shippo-dust.png',scale=0.44,rects={ + {0,760,362,326}, + {362,760,362,326}, + {724,760,362,326}, + {1086,760,362,326}, + },offsets={ + {-70,-228}, + {-188,-237}, + {-176,-229}, + {-178,-209}, + }}, +} diff --git a/plugins/inuyasha/ink.lua b/plugins/inuyasha/ink.lua new file mode 100644 index 0000000..cbb44a5 --- /dev/null +++ b/plugins/inuyasha/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 at3 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=(#k.label>1 and left[k.key]) and '#FFF2DD' or I.color}) + end + -- Static bead, fang and woven corner ornaments; no moving overlay on keys. + for _,x in ipairs({25,775}) do + for i,y in ipairs({43,58,73,222,237,252}) do + add({type='ellipse',id='bead-'..x..'-'..i,x=x-3.5,y=y,width=7,height=7,fill='#725486',stroke='#332C3B',strokeWidth=1}) + add({type='ellipse',id='bead-glint-'..x..'-'..i,x=x-1.5,y=y+1,width=2,height=2,fill='#EEE0D1'}) + end + add({type='path',id='fang-'..x,path={{'M',x-3,82},{'Q',x-8,94,x,106},{'Q',x+3,91,x+3,82},{'Z'}},fill='#FFF3D9',stroke='#4A3B37',strokeWidth=1.2}) + end + for i,v in ipairs({{27,7},{747,7},{19,401},{753,401}}) do + add({type='rect',id='corner-'..i,x=v[1],y=v[2],width=24,height=10,fill='#B74D43',stroke='#E8CBA4',strokeWidth=1}) + add({type='path',id='diamond-'..i,path={{'M',v[1]+12,v[2]+2},{'L',v[1]+16,v[2]+5},{'L',v[1]+12,v[2]+8},{'L',v[1]+8,v[2]+5},{'Z'}},stroke='#F2DBB3',strokeWidth=1}) + end + add({type='path',id='dog-ears',path={{'M',421,370},{'L',416,355},{'Q',423,356,429,362},{'Q',434,358,440,355},{'L',437,370}},stroke='#B94D42',strokeWidth=1.5}) + 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 '#EDB09A' or keyFill(k)}) + 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 + 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/inuyasha/keys.lua b/plugins/inuyasha/keys.lua new file mode 100644 index 0000000..92f61a5 --- /dev/null +++ b/plugins/inuyasha/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/inuyasha/main.lua b/plugins/inuyasha/main.lua new file mode 100644 index 0000000..f0680a7 --- /dev/null +++ b/plugins/inuyasha/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=='inuyasha';reset() end, + update=function(frame) + if active then S.update(M.update(state,frame,K.update(frame))) end + end, + appearanceChanged=function(a) miucs.set('inuyasha',{opacity=a.opacity or 1}) end, + resize=function() end, + deactivate=function() active=false;reset() end, + dispose=function() active=false end +} diff --git a/plugins/inuyasha/manifest.json b/plugins/inuyasha/manifest.json new file mode 100644 index 0000000..49c9f6f --- /dev/null +++ b/plugins/inuyasha/manifest.json @@ -0,0 +1,17 @@ +{ + "id": "inuyasha", + "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/inuyasha/motion.lua b/plugins/inuyasha/motion.lua new file mode 100644 index 0000000..716c17f --- /dev/null +++ b/plugins/inuyasha/motion.lua @@ -0,0 +1,93 @@ +-- Model-drawn poses only. A command owns all three actors until recovery finishes. +local M={} +local command={ + inuyasha={{1,.10},{5,.10},{6,.12},{7,.08},{8,.08},{9,.06},{10,.08},{11,.10},{12,.48},{13,.18},{14,.18},{15,.16},{16,.12}}, + kagome={{3,.10},{4,.12},{5,.10},{6,.08},{7,.08},{8,.14},{9,.42},{10,.14},{11,.18},{12,.12}}, + shippo={{1,.40},{3,.08},{4,.14},{5,.14},{6,.16},{7,.40},{8,.16}}, +} +local boast={{3,.10},{4,.24},{5,.14},{16,.12}} +local duration=1.94 +local function pose(age,list) + local ending=0 + for _,v in ipairs(list) do ending=ending+v[2];if age.5 then + s.commandAge=10;s.boastAge=10;s.pending=0;resetAudio(s) + s.audioCursor=math.max(s.audioCursor,through) + s.inuyashaFrame=1;s.kagomeFrame=1;s.shippoFrame=1;s.dustVisible=false + return s + end + s.commandAge=s.commandAge+dt;s.boastAge=s.boastAge+dt + if hits.reset then s.boastAge=10;s.pending=0 end + if s.commandAge>=duration then + if hits.left or hits.right then + if s.boastAge>=.60 then s.boastAge=0;s.pending=0 else s.pending=s.time+.16 end + elseif s.boastAge>=.60 and s.pending>0 then + if s.pending>=s.time then s.boastAge=0 end + s.pending=0 + end + else s.pending=0 end + 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)>=.6 and (e.confidence or 2)<=1 + and (e.bpm or 0)>=50 and (e.bpm or 0)<=200 + and (e.strength or -1)>=.35 and (e.strength or 2)<=1 then accepted=e end + end + end + if accepted and (input.droppedBeatCount or 0)==0 then + if s.lastBeat and s.time-s.lastBeat>2 then s.beatCount=0 end + s.lastBeat=s.time;s.beatCount=s.beatCount+1 + -- No queued commands; high BPM cannot interrupt a fall or recovery. + if (s.beatCount-1)%4==0 and s.commandAge>=duration then + s.commandAge=0;s.boastAge=10;s.pending=0 + end + end + else + -- Finish an already-started physical action; silence never starts a new one. + resetAudio(s) + end + s.audioCursor=math.max(s.audioCursor,through) + s.dustVisible=s.commandAge>=.48 and s.commandAge<.84 + s.dustFrame=s.dustVisible and math.min(4,1+math.floor((s.commandAge-.48)/.09)) or 1 + if s.commandAge5.16 and 2 or 1) + s.kagomeFrame=(s.time+1.7)%6.1>5.95 and 2 or 1 + s.shippoFrame=(s.time+.6)%4.9>4.74 and 2 or 1 + end + return s +end +return M diff --git a/plugins/inuyasha/scene.lua b/plugins/inuyasha/scene.lua new file mode 100644 index 0000000..a26d93f --- /dev/null +++ b/plugins/inuyasha/scene.lua @@ -0,0 +1,27 @@ +local K=miucs.require('keyboard.lua') +local A=miucs.require('frames.lua') +local S={} +local actors={inuyasha={x=202,y=158},kagome={x=627,y=158},shippo={x=407,y=158},dust={x=202,y=158}} +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={{type='image',id='backdrop',source='assets/backdrop.png',x=36,y=-80,width=728,height=728/3,sampling='linear'}} + for _,name in ipairs({'dust','inuyasha','kagome','shippo'}) 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..'-actor',x=v.x,y=v.y,children={art}} + end + nodes[#nodes+1]={type='group',id='stage',clip={39,19,722,142},z=5,children=stage} + return {type='group',id='inuyasha',width=800,height=460,children=nodes} +end +function S.update(s) + for _,name in ipairs({'dust','inuyasha','kagome','shippo'}) do + local art=pose(A[name],s[name..'Frame']) + if name=='dust' then art.opacity=s.dustVisible and 1 or 0 end + miucs.set(name..'-art',art) + end +end +return S diff --git a/scripts/test-plugins.sh b/scripts/test-plugins.sh index f322910..22f93d0 100644 --- a/scripts/test-plugins.sh +++ b/scripts/test-plugins.sh @@ -26,3 +26,6 @@ python3 "$root/tests/plugins/zelda_replay.py" python3 "$root/tools/plugins/validate.py" "$root/plugins/yuru-camp" python3 "$root/tests/plugins/camp_replay.py" + +python3 "$root/tools/plugins/validate.py" "$root/plugins/inuyasha" +python3 "$root/tests/plugins/inuyasha_replay.py" diff --git a/tests/platforms/macos/VisualizationAppearanceTests.swift b/tests/platforms/macos/VisualizationAppearanceTests.swift index ef9605a..5c7a323 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", "zelda-guardian", "yuru-camp"] { + for id in ["pixel-keyboard", "muffin-puppies", "niulai-keyboard", "heiji-kazuha", "zelda-guardian", "yuru-camp", "inuyasha"] { 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", "zelda-guardian", "yuru-camp", "pixel-keyboard"] { + for id in ["muffin-puppies", "niulai-keyboard", "heiji-kazuha", "zelda-guardian", "yuru-camp", "inuyasha", "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 six modes inherit transparency while adjustment overlays remain opaque", opacitySurvivesSwitch && panel.alphaValue == 1) + check("All seven 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 3dbba54..9fe1e60 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","zelda-guardian","yuru-camp"}) { + for(auto id:{"pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha","zelda-guardian","yuru-camp","inuyasha"}) { 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 7df32ba..49d499d 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","zelda-guardian","yuru-camp"}; + const std::set builtinIDs={"pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha","zelda-guardian","yuru-camp","inuyasha"}; 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/inuyasha_replay.py b/tests/plugins/inuyasha_replay.py new file mode 100644 index 0000000..4cf9aa0 --- /dev/null +++ b/tests/plugins/inuyasha_replay.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Production worker replay: synchronized slapstick, input integrity 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') +registration=json.loads((ROOT/'docs/concepts/inuyasha/frame-registration.json').read_text()) +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/inuyasha')],stdin=subprocess.PIPE,stdout=subprocess.PIPE) + self.scene={};self.version=0;self.index=0;self.timings=[] + self.seen={name:set() for name in registration} + self.read();self.fixed={name:copy.deepcopy(self.scene[name+'-actor']) for name in registration} + 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(self.pose(name)) + if hasattr(self,'fixed'): + for name,expected in self.fixed.items(): + assert self.scene[name+'-actor']==expected,'Actor world anchor changed' + art=self.scene[name+'-art'];rect=art['sourceRect'];a=registration[name] + assert abs(art['width']/rect[2]-a['scale'])<1e-8 and abs(art['height']/rect[3]-a['scale'])<1e-8,'Art stretched' + off=a['offsets'][self.pose(name)-1] + assert abs(art['x']-off[0]*a['scale'])<1e-8 and abs(art['y']-off[1]*a['scale'])<1e-8,'Contact drifted' + def pose(self,name):return registration[name]['rects'].index(self.scene[name+'-art']['sourceRect'])+1 + def settled(self):return all(self.pose(n) in (1,2) for n in ('inuyasha','kagome','shippo')) and self.scene['dust-art'].get('opacity',1)==0 + 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':'inuyasha','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':k,'duration':.1} for k 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 close(self): + if self.proc.poll() is None:self.proc.kill() + self.proc.wait(timeout=3) + +h=Harness() +try: + topology=len(h.scene);ink=copy.deepcopy(h.scene['monitor-ink-1']['path']) + check(not any(n.get('type')=='text' and not n['id'].startswith('label-') for n in h.scene.values()),'No dialogue or bubble text, only key labels') + for _ in range(420): + h.tick(audio=False);check(h.settled(),'idle blinks without invented commands') + for key in ('a','j','space','returnKey','digit1'): + h.activate();h.tick(key=key,audio=False);h.advance(10,audio=False) + check(h.pose('inuyasha')==4,key+' triggers boast without music') + check(h.pose('kagome') in (1,2) and h.pose('shippo') in (1,2),'typing cannot fabricate pointing or surprise') + h.advance(45,audio=False);check(h.settled(),'one press finishes one boast') + h.activate();h.tick(key='a',held=('a',),audio=False) + check(h.scene['key-a-fill']['fill']=='#EDB09A','held key glows coral') + for _ in range(100):h.tick(key='a',kind='repeated',held=('a',),audio=False) + check(h.settled(),'OS repeat cannot create endless boasting') + h.tick(key='a',kind='released',audio=False) + check(h.scene['key-a-fill']['fill']=='#FFF7E7','release clears key') + h.activate();f=h.sample(key='a',held=('a','j'),audio=False);f['keyboard']['sequence']+=1 + f['keyboard']['events'].append(dict(f['keyboard']['events'][0],sequence=f['keyboard']['sequence'],key='j')) + h.post('update',f);check(all(h.scene['key-'+k+'-fill']['fill']=='#EDB09A' for k in ('a','j')),'chord highlights both keys') + for mutate,label in [ + (lambda f:f['keyboard'].update(state='secureInput'),'secure input'), + (lambda f:f['keyboard'].update(sampledAt=f['now']-.5),'stale snapshot'), + (lambda f:f['keyboard'].update(droppedEventCount=3),'lost events'), + (lambda f:f['keyboard']['events'][0].update(receivedAt=f['now']-.5),'expired press'), + (lambda f:f['keyboard']['events'][0].update(receivedAt=f['now']+1),'future press'), + ]: + h.activate();f=h.sample(key='a',audio=False);mutate(f);h.post('update',f);h.advance(10,audio=False) + check(h.settled(),label+' cannot trigger boast') + 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(10,audio=False);check(h.pose('inuyasha')==4,'modifier triggers boast') + h.activate();f=h.sample(key='a',audio=False);h.post('update',f);h.advance(50,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(10,audio=False);check(h.settled(),'duplicate keyboard sequence ignored') + h.activate() + for _ in range(80): + f=h.sample(audio=False);f['keyboard']['pressesPerSecond']=200;h.post('update',f) + check(h.settled(),'typing summary cannot invent events') + + h.activate();h.tick(beat=True);h.advance(30) + check(h.pose('inuyasha')==9 and h.pose('kagome')==8 and h.pose('shippo')==4,'point, fall and surprise synchronize at impact') + check(h.scene['dust-art']['opacity']==1,'impact dust visible at contact') + h.tick(key='a');check(h.pose('inuyasha') in (9,10),'typing cannot interrupt fall') + h.advance(45);check(h.pose('inuyasha')==13,'fallen character pushes up before standing') + h.advance(50);check(h.settled(),'complete recovery returns to idle') + h.activate() + for i in range(120):h.tick(beat=i%30==0) + check(h.settled(),'beats two through four cannot restart command') + h.tick(beat=True);h.advance(30);check(h.pose('inuyasha')==9,'fifth beat starts a fresh command') + for mutate,label in [ + (lambda f:f['audio']['frame']['audio'].update(isSilent=True),'silence'), + (lambda f:f['audio']['beats'][0].update(confidence=.2),'low confidence'), + (lambda f:f['audio']['beats'][0].update(strength=.2),'weak beat'), + (lambda f:f['audio']['beats'][0].update(bpm=250),'invalid tempo'), + (lambda f:f['audio']['beats'][0].update(receivedAt=f['now']-.5),'expired beat'), + (lambda f:f['audio']['beats'][0].update(receivedAt=f['now']+1),'future beat'), + (lambda f:f['audio'].update(frameAge=.8),'old audio'), + (lambda f:f['audio'].update(droppedBeatCount=2),'lost beats'), + (lambda f:f['audio']['frame'].update(sessionID='wrong'),'wrong session'), + ]: + h.activate();f=h.sample(beat=True);mutate(f);h.post('update',f);h.advance(30) + check(h.settled(),label+' cannot trigger command') + h.activate();f=h.sample(beat=True);h.post('update',f);h.advance(130) + f['now']=h.sample()['now'];f['audio']['frame']['receivedAt']=f['now'];f['audio']['frame']['audio']['time']=f['now']+5000 + f['audio']['beats'][0]['receivedAt']=f['now'];h.post('update',f);h.advance(30) + check(h.settled(),'duplicate beat ignored') + h.activate();h.tick(beat=True);h.advance(20) + f=h.sample();f['audio'].update(state='recovering',frame=None,frameAge=None,beats=[]);h.post('update',f);h.advance(10,audio=False) + check(h.pose('inuyasha') in (9,10),'audio loss finishes already-started physical action') + h.advance(160,audio=False);check(h.settled(),'audio loss leaves no queued command') + h.activate();f=h.sample(beat=True);f['keyboard']['state']='secureInput';h.post('update',f);h.advance(30) + check(h.pose('inuyasha')==9,'keyboard loss does not stop beat action') + h.activate();h.tick(key='a',audio=False);h.advance(10,audio=False) + check(h.pose('inuyasha')==4,'audio absence preserves typing response') + 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) + for i in range(30): + f=h.sample();f['audio']['sessionID']='new';f['audio']['frame'].update(sessionID='new',sequence=i+2);h.post('update',f) + check(h.pose('inuyasha')==9,'new audio session accepts fresh low sequence') + h.tick(key='j',beat=True,deltaTime=2);check(h.settled(),'long stall clears all action backlogs') + # Busy at beat five under 200 BPM: never restart mid-recovery. + h.activate() + for i in range(85): + f=h.sample(beat=i%18==0) + for b in f['audio']['beats']:b['bpm']=200 + h.post('update',f) + check(h.pose('inuyasha')==14,'high BPM cannot interrupt recovery') + h.advance(180);check(h.settled(),'busy command is discarded, not queued') + h.activate() + for i in range(900): + phase=i%90;key='a' if i//90%2==0 else 'j' + h.tick(key=key if phase==0 else None,held=(key,) if phase<8 else (),beat=i%30==0) + h.advance(150,audio=False) + check(len(h.scene)==topology,'15 seconds of mixed input keeps fixed node count') + check({k:len(v) for k,v in h.seen.items()}=={'inuyasha':16,'kagome':12,'shippo':8,'dust':4},'all 40 drawn frames are reachable') + for i in range(240):h.tick(key='a' if i%2 else 'j',audio=False) + h.advance(100,audio=False);check(h.settled(),'fast typing leaves no accumulated boast') + check(h.scene['monitor-ink-1']['path']==ink,'static outlines do not jitter') + h.post('appearanceChanged',{'opacity':.4,'ink':'#000000'});check(h.scene['inuyasha']['opacity']==.4,'host opacity retained') + h.post('resize',{'width':400,'height':230,'scale':2});check(len(h.scene)==topology,'resize preserves scene') + h.post('deactivate');before=copy.deepcopy(h.scene);h.tick(key='a',beat=True);check(h.scene==before,'inactive skin ignores input') + h.activate();check(h.settled(),'reactivation resets to idle') + h.post('dispose') + report=dict(checks=checks,nodeCount=topology,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,noDialogueText=True, + inputs='synthetic, production Lua worker',nativeDesktopAcceptance=False) + (ROOT/'docs/validation/inuyasha.json').write_text(json.dumps(report,indent=2)+'\n');print(json.dumps(report,indent=2)) +finally:h.close() diff --git a/tests/plugins/mac_reload.swift b/tests/plugins/mac_reload.swift index 8c08f95..2967568 100644 --- a/tests/plugins/mac_reload.swift +++ b/tests/plugins/mac_reload.swift @@ -52,12 +52,13 @@ 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","zelda-guardian","yuru-camp"]) + let ids=Set(["pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha","zelda-guardian","yuru-camp","inuyasha"]) 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.descriptor(id:"pixel-keyboard")?.name=="奶牛猫") precondition(registry.descriptor(id:"yuru-camp")?.name=="摇曳露营") + precondition(registry.descriptor(id:"inuyasha")?.name=="犬夜叉") let suite="MiucsCuratedPlugins-"+UUID().uuidString let preferences=UserDefaults(suiteName:suite)! defer { preferences.removePersistentDomain(forName:suite) } diff --git a/tests/plugins/soak.py b/tests/plugins/soak.py index 3ccadf6..c89728a 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','zelda-guardian','yuru-camp'][int(elapsed//10)%6] + mode=['pixel-keyboard','muffin-puppies','niulai-keyboard','heiji-kazuha','zelda-guardian','yuru-camp','inuyasha'][int(elapsed//10)%7] 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 8c4d1ad..35e6ccd 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","zelda-guardian","yuru-camp"}) { + for(auto id:{"pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha","zelda-guardian","yuru-camp","inuyasha"}) { 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/tools/plugins/inuyasha_preview.py b/tools/plugins/inuyasha_preview.py new file mode 100644 index 0000000..41fb98d --- /dev/null +++ b/tools/plugins/inuyasha_preview.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Render twelve 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/inuyasha') +args=p.parse_args();args.output.mkdir(parents=True,exist_ok=True) +b=Bridge(ROOT/'plugins/inuyasha') +frames=[] + +def render(width=800,height=460): + raw=b.render('inuyasha',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':'inuyasha','width':800,'height':460,'scale':1}) + for i in range(720): + f=frame(i) + audio_active=240<=i<600 + typing=60<=i<240 or 420<=i<600 + 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//75)%8] + phase=i%75 + 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,80,262,280): + name={30:'runtime-idle.png',80:'runtime-boasting.png',262:'runtime-pointing.png',280:'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 12s / 360 sampled frames: real Skia rendering, transparent WebP, GIF, 2x snapshots.') +finally: b.close() diff --git a/tools/plugins/register_inuyasha_frames.py b/tools/plugins/register_inuyasha_frames.py new file mode 100644 index 0000000..775fdf5 --- /dev/null +++ b/tools/plugins/register_inuyasha_frames.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Register original model-drawn RGBA cells; never alter sprite pixels.""" +import json +from pathlib import Path +from PIL import Image +ROOT=Path(__file__).resolve().parents[2] +PACKAGE=ROOT/'plugins/inuyasha' +def register(source,ys,scale,indices=None): + im=Image.open(PACKAGE/source) + assert im.mode=='RGBA' and im.getextrema()[3][0]==0 + regions=[] + pixels=im.getchannel('A').load() + ys=[0]+[min(range(e-20,e+21),key=lambda y:(sum(pixels[x,yy]>96 for yy in range(y-1,y+2) for x in range(im.width)),abs(y-e))) for e in ys[1:-1]]+[im.height] + for y0,y1 in zip(ys,ys[1:]): + xs=[0] + for c in range(1,4): + expected=round(c*im.width/4) + candidates=range(expected-30,expected+31) + cut=min(candidates,key=lambda x:(sum(pixels[xx,y]>96 for xx in range(x-2,x+3) for y in range(y0,y1)),abs(x-expected))) + xs.append(cut) + xs.append(im.width) + regions.extend([xs[c],y0,xs[c+1]-xs[c],y1-y0] for c in range(4)) + if indices is not None: regions=[regions[i] for i in indices] + out=dict(source=source,scale=scale,rects=regions,offsets=[],bounds=[],contacts=[]) + for x,y,w,h in regions: + alpha=im.crop((x,y,x+w,y+h)).getchannel('A').point(lambda a:255 if a>96 else 0) + l,t,r,b=alpha.getbbox() + assert l>=1 and t>=1 and r