diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index 9463c2c..ff06dae 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -78,6 +78,10 @@ jobs: 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/validate.py plugins/yuru-camp + 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/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 34286d6..f4179b7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ > 预览使用合成音乐与按键输入,经正式 Lua/Skia 绘制器生成;GIF 无声,不是桌面录屏。 -## 五个桌面伙伴 +## 六个桌面伙伴 | 插件 | 效果 | 详细说明 | | --- | --- | --- | @@ -19,8 +19,9 @@ | 牛来 | 牛蹄响应按键,上方角色随音乐和打字运动 | [牛来](plugins/niulai-keyboard/README.md) | | 平次与和叶 | 平次随打字挥木剑,和叶随节拍转身 | [平次与和叶](plugins/heiji-kazuha/README.md) | | 塞尔达 | 林克随按键挥剑、举盾,守护者随拍点发射激光 | [塞尔达](plugins/zelda-guardian/README.md) | +| 摇曳露营 | 抚子随打字吃面,凛随音乐捧杯轻啜,篝火随拍点轻跳 | [摇曳露营](plugins/yuru-camp/README.md) | -[查看五个插件的动态预览](docs/plugin-previews.md)。首次启动默认选择奶牛猫;之后记住你的选择。每个插件分别保存位置和大小,切换不重启输入采集。 +[查看六个插件的动态预览](docs/plugin-previews.md)。首次启动默认选择奶牛猫;之后记住你的选择。每个插件分别保存位置和大小,切换不重启输入采集。 ## 构建与启动 @@ -41,7 +42,7 @@ open dist/Miucs.app ## 常用操作 -- **可视化**:切换五个桌面伙伴,奶牛猫排在首位。 +- **可视化**:切换六个桌面伙伴,奶牛猫排在首位。 - **显示 / 隐藏画面**:控制浮窗显示。 - **画面大小、不透明度**:等比缩放,调整 10%~100% 不透明度。 - **调整位置与大小**:拖动画面或右下角缩放,完成后恢复鼠标穿透;也可悬停 3 秒点击小锁。 @@ -49,7 +50,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 5ca277c..4418a04 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" }) + foreach (string id in new[] { "pixel-keyboard", "muffin-puppies", "niulai-keyboard", "heiji-kazuha", "zelda-guardian", "yuru-camp" }) { if (!host.Views.ContainsKey(id)) throw new InvalidOperationException("Missing bundled plugin: " + id); using var view = new PluginView(host, id); diff --git a/docs/concepts/yuru-camp/camp-props-prompt.txt b/docs/concepts/yuru-camp/camp-props-prompt.txt new file mode 100644 index 0000000..2d98455 --- /dev/null +++ b/docs/concepts/yuru-camp/camp-props-prompt.txt @@ -0,0 +1,23 @@ +Tool: built-in image_gen; transparent_background=true +Reference: skin-concept-v1.png + +Use case: stylized-concept. Production sprite atlas of CAMPFIRE and TYPING MITTENS for the approved cozy Yuru Camp keyboard skin. Match reference's bold organic black contour, soft flat hand-colored cel shading, warm orange/coral and muted navy-blue. Draw NO people, keyboard, screen, scenery or text. + +One square transparent atlas with exactly FOUR columns and FOUR rows = SIXTEEN equal square cells. True transparent background. SMALL sprites in central 55% of each cell, HUGE transparent gutters at least 18% on all sides. Fixed scale and local anchors throughout. No grid, numbering, labels, logos, shadows outside individual cells or watermark. + +TOP TWO ROWS, frames1-8: one small camping campfire in a dark charcoal metal shallow bowl with crossed short legs, as seen between characters in reference. Bowl and legs EXACTLY stationary, same size and position in every frame, ground baseline at local y78%. Draw a gentle single fire flicker pulse in sequence: +1 low quiet warm flame. +2 flame gathers at center. +3 center flame stretches up. +4 bright tallest flame with one tiny close ember. +5 flame leans gently to right, second lobe curls. +6 flame settles and curls left. +7 near idle smaller flame. +8 EXACT same low quiet flame as frame1. +Only flame shapes, orange/yellow layers and the one close ember change. No explosions, no sparks far outside cell. The low fire must remain visibly lit in idle. Dark bowl below flame must always be visible, not hidden by a huge fire. + +THIRD ROW, frames9-12: LEFT mitten hand ONLY, rounded CORAL RED knitted mitten, cream ribbed sleeve cuff, thumb on viewer RIGHT in ALL FOUR cells, fingers point upward, cuff at bottom. Same cuff anchor local x50%, y75%. Four press poses: relaxed hovering mitten; bends toward key; compressed mitten pressing invisible key; rebounds to relaxed stance. Mitten is one rounded finger pocket plus one thumb, NOT separated fingers. + +FOURTH ROW, frames13-16: RIGHT mitten hand ONLY, muted NAVY BLUE knitted mitten, cream ribbed sleeve cuff, thumb on viewer LEFT in ALL FOUR cells. Same four sequential press poses as row3. Same orientation within a row, do NOT mirror the last column. Keep sleeve cuffs identical size and place, no long arms or extra objects. No actual keycaps. Hands are near-overhead view as in reference, clear at 35px rendered size. + +No sprites may touch adjacent cells. Exactly eight fire poses, four coral left-mitten poses and four blue right-mitten poses. diff --git a/docs/concepts/yuru-camp/frame-registration.json b/docs/concepts/yuru-camp/frame-registration.json new file mode 100644 index 0000000..619b7b9 --- /dev/null +++ b/docs/concepts/yuru-camp/frame-registration.json @@ -0,0 +1,912 @@ +{ + "nadeshiko": { + "source": "assets/nadeshiko-eat.png", + "scale": 0.42, + "rects": [ + [ + 0, + 0, + 362, + 365 + ], + [ + 362, + 0, + 362, + 365 + ], + [ + 724, + 0, + 362, + 365 + ], + [ + 1086, + 0, + 362, + 365 + ], + [ + 0, + 365, + 362, + 345 + ], + [ + 362, + 365, + 362, + 345 + ], + [ + 724, + 365, + 362, + 345 + ], + [ + 1086, + 365, + 362, + 345 + ], + [ + 0, + 710, + 362, + 376 + ], + [ + 362, + 710, + 362, + 376 + ], + [ + 724, + 710, + 362, + 376 + ], + [ + 1086, + 710, + 362, + 376 + ] + ], + "offsets": [ + [ + -200, + -352 + ], + [ + -192, + -352 + ], + [ + -184, + -352 + ], + [ + -181, + -352 + ], + [ + -200, + -332 + ], + [ + -196, + -332 + ], + [ + -185, + -332 + ], + [ + -180, + -332 + ], + [ + -202, + -335 + ], + [ + -196, + -335 + ], + [ + -188, + -335 + ], + [ + -186, + -335 + ] + ], + "contacts": [ + [ + 200, + 352 + ], + [ + 554, + 352 + ], + [ + 908, + 352 + ], + [ + 1267, + 352 + ], + [ + 200, + 697 + ], + [ + 558, + 697 + ], + [ + 909, + 697 + ], + [ + 1266, + 697 + ], + [ + 202, + 1045 + ], + [ + 558, + 1045 + ], + [ + 912, + 1045 + ], + [ + 1272, + 1045 + ] + ], + "bounds": [ + [ + 49, + 34, + 355, + 352 + ], + [ + 406, + 37, + 706, + 352 + ], + [ + 758, + 33, + 1062, + 352 + ], + [ + 1116, + 34, + 1420, + 352 + ], + [ + 49, + 382, + 359, + 697 + ], + [ + 406, + 382, + 713, + 697 + ], + [ + 760, + 382, + 1063, + 697 + ], + [ + 1117, + 382, + 1421, + 697 + ], + [ + 48, + 723, + 355, + 1045 + ], + [ + 407, + 732, + 712, + 1045 + ], + [ + 764, + 729, + 1068, + 1045 + ], + [ + 1120, + 726, + 1425, + 1045 + ] + ], + "mirrored": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "rin": { + "source": "assets/rin-sip.png", + "scale": 0.45, + "rects": [ + [ + 0, + 0, + 362, + 375 + ], + [ + 362, + 0, + 362, + 375 + ], + [ + 724, + 0, + 362, + 375 + ], + [ + 1086, + 0, + 362, + 375 + ], + [ + 0, + 375, + 362, + 335 + ], + [ + 362, + 375, + 362, + 335 + ], + [ + 724, + 375, + 362, + 335 + ], + [ + 1086, + 375, + 362, + 335 + ], + [ + 0, + 710, + 362, + 376 + ], + [ + 362, + 710, + 362, + 376 + ], + [ + 724, + 710, + 362, + 376 + ], + [ + 1086, + 710, + 362, + 376 + ] + ], + "offsets": [ + [ + -202, + -356 + ], + [ + -196, + -356 + ], + [ + -177, + -356 + ], + [ + -167, + -356 + ], + [ + -202, + -316 + ], + [ + -190, + -316 + ], + [ + -183, + -315 + ], + [ + -174, + -315 + ], + [ + -198, + -319 + ], + [ + -192, + -319 + ], + [ + -181, + -319 + ], + [ + -167, + -319 + ] + ], + "contacts": [ + [ + 202, + 356 + ], + [ + 558, + 356 + ], + [ + 901, + 356 + ], + [ + 1253, + 356 + ], + [ + 202, + 691 + ], + [ + 552, + 691 + ], + [ + 907, + 690 + ], + [ + 1260, + 690 + ], + [ + 198, + 1029 + ], + [ + 554, + 1029 + ], + [ + 905, + 1029 + ], + [ + 1253, + 1029 + ] + ], + "bounds": [ + [ + 53, + 59, + 341, + 356 + ], + [ + 408, + 60, + 697, + 356 + ], + [ + 753, + 59, + 1038, + 356 + ], + [ + 1105, + 57, + 1389, + 356 + ], + [ + 51, + 397, + 338, + 691 + ], + [ + 404, + 397, + 692, + 691 + ], + [ + 759, + 398, + 1045, + 690 + ], + [ + 1113, + 393, + 1396, + 690 + ], + [ + 51, + 733, + 339, + 1029 + ], + [ + 405, + 730, + 694, + 1029 + ], + [ + 755, + 730, + 1045, + 1029 + ], + [ + 1104, + 730, + 1392, + 1029 + ] + ], + "mirrored": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "fire": { + "source": "assets/camp-props.png", + "scale": 0.32, + "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 + ] + ], + "offsets": [ + [ + -172, + -310 + ], + [ + -163, + -310 + ], + [ + -155, + -310 + ], + [ + -147, + -310 + ], + [ + -170, + -287 + ], + [ + -164, + -287 + ], + [ + -155, + -287 + ], + [ + -150, + -287 + ] + ], + "contacts": [ + [ + 172, + 310 + ], + [ + 477, + 310 + ], + [ + 782, + 310 + ], + [ + 1087, + 310 + ], + [ + 170, + 612 + ], + [ + 478, + 612 + ], + [ + 782, + 612 + ], + [ + 1090, + 612 + ] + ], + "bounds": [ + [ + 72, + 162, + 271, + 310 + ], + [ + 378, + 144, + 578, + 310 + ], + [ + 682, + 113, + 882, + 310 + ], + [ + 987, + 90, + 1187, + 310 + ], + [ + 71, + 429, + 270, + 612 + ], + [ + 378, + 425, + 578, + 612 + ], + [ + 682, + 464, + 882, + 612 + ], + [ + 990, + 470, + 1190, + 612 + ] + ], + "mirrored": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "leftHand": { + "source": "assets/camp-props.png", + "scale": 0.2, + "rects": [ + [ + 0, + 640, + 314, + 295 + ], + [ + 314, + 640, + 313, + 295 + ], + [ + 627, + 640, + 313, + 295 + ], + [ + 940, + 640, + 314, + 295 + ] + ], + "offsets": [ + [ + -172, + -239 + ], + [ + -158, + -243 + ], + [ + -138, + -239 + ], + [ + -143, + -239 + ] + ], + "contacts": [ + [ + 172, + 879 + ], + [ + 472, + 883 + ], + [ + 765, + 879 + ], + [ + 1083, + 879 + ] + ], + "bounds": [ + [ + 107, + 695, + 272, + 879 + ], + [ + 393, + 696, + 586, + 883 + ], + [ + 702, + 709, + 863, + 879 + ], + [ + 983, + 695, + 1148, + 879 + ] + ], + "mirrored": [ + false, + false, + false, + true + ] + }, + "rightHand": { + "source": "assets/camp-props.png", + "scale": 0.2, + "rects": [ + [ + 0, + 935, + 314, + 319 + ], + [ + 314, + 935, + 313, + 319 + ], + [ + 627, + 935, + 313, + 319 + ], + [ + 940, + 935, + 314, + 319 + ] + ], + "offsets": [ + [ + -196, + -220 + ], + [ + -174, + -225 + ], + [ + -162, + -220 + ], + [ + -120, + -220 + ] + ], + "contacts": [ + [ + 196, + 1155 + ], + [ + 488, + 1160 + ], + [ + 789, + 1155 + ], + [ + 1060, + 1155 + ] + ], + "bounds": [ + [ + 100, + 974, + 262, + 1155 + ], + [ + 384, + 968, + 574, + 1160 + ], + [ + 694, + 982, + 856, + 1155 + ], + [ + 995, + 974, + 1158, + 1155 + ] + ], + "mirrored": [ + false, + false, + false, + true + ] + } +} diff --git a/docs/concepts/yuru-camp/keyframes.md b/docs/concepts/yuru-camp/keyframes.md new file mode 100644 index 0000000..480789e --- /dev/null +++ b/docs/concepts/yuru-camp/keyframes.md @@ -0,0 +1,20 @@ +# 摇曳露营 · 关键帧设计 + +依据确认的第一版概念图,保持上方横屏和下方五行键盘。所有角色动作由 imagegen 绘制,代码负责输入判定和选帧;不通过程序变形、旋转或平移整个人物伪造动作。 + +| 图集 | 帧 | 关键动作 | 驱动 | +| --- | --- | --- | --- | +| 抚子吃面 | 1–3 | 捧杯静止、筷子探入、挑起面条 | 新按键启动 | +| | 4–7 | 面条靠近嘴、张嘴、吸面、收掉最后一小段 | 同一段动画继续 | +| | 8–12 | 闭嘴咀嚼、满足地眯眼、放低筷子、回到捧杯 | 不被新按键打断 | +| 凛喝茶 | 1–4 | 捧杯、看杯子、缓慢举杯、杯沿靠近嘴 | 首拍及之后每四拍;只在上一动作完成后启动 | +| | 5–8 | 轻啜、短暂保持、放低一点、满足地呼气 | 保持慢节奏 | +| | 9–12 | 杯子回胸前、睁眼、放松、回到静止 | 不循环补拍 | +| 篝火 | 1–8 | 小火、聚拢、升高、峰值、右卷、左卷、回落、小火 | 每个可靠音乐拍点 | +| 左右手套 | 各 1–4 | 悬停、弯曲、压下、回弹 | 真实键位状态 | + +共 40 个绘制帧。抚子约 1.05 秒一口面,凛约 1.6 秒一口茶;密集按键最多保留一个仍在有效期内的后续动作。篝火小幅跳动,静音后保留低火静帧。 + +椅脚、火盆脚与手套袖口作为固定锚点。保留生成的透明 PNG,通过 sourceRect 与注册偏移读取原像素,统一像素缩放;不对不同帧拉伸。角色头部、帽子和椅子都留在显示区内。 + +键盘沿用真实五行键位、双手跟随与多键反馈;深蓝框、奶油键帽、暖杏色压键、珊瑚色格纹包角。键盘和音频分别处理权限、会话、去重、断连和过期数据;隐藏恢复或卡顿不补播。 diff --git a/docs/concepts/yuru-camp/nadeshiko-eat-prompt.txt b/docs/concepts/yuru-camp/nadeshiko-eat-prompt.txt new file mode 100644 index 0000000..0c1ad30 --- /dev/null +++ b/docs/concepts/yuru-camp/nadeshiko-eat-prompt.txt @@ -0,0 +1,25 @@ +Tool: built-in image_gen; transparent_background=true +Reference: skin-concept-v1.png + +Use case: stylized-concept. Production animation keyframe atlas for the approved Yuru Camp desktop skin. +Reference image is the approved CHARACTER DESIGN reference. Extract ONLY Nadeshiko Kagamihara, the pink-haired girl seated on the LEFT, with her entire camping chair, coral patterned blanket, cup noodles and chopsticks. No Rin, fire, tent, keyboard, frame or background. + +Create exactly TWELVE consecutive animation frames in FOUR columns and THREE rows, a 4:3 landscape canvas. Genuine transparent background. Each virtual cell is square. Place SMALL full seated character in the central 60% of each cell with at least 15% transparent gutters on all sides. Never fill each cell edge-to-edge. No text, numbers, visible grid, borders or watermarks. Entire chair and all chair feet must be fully visible in every frame with generous bottom padding. Fixed camera, constant scale and identical chair geometry. Chair floor contact at local 80% cell height in every frame, center at local 50% cell width. + +Design: charming chibi Nadeshiko with long pink hair, recognizably soft pink bangs and small top tuft, warm cream puffy winter jacket, mint/coral striped scarf, warm blush, coral lap blanket with simple cream geometric pattern, brown folding camping chair. Use clean bold slightly hand-drawn black contour, flat warm cel colors and minimal texture, matching reference. Three-quarter view facing slightly RIGHT. Cute small face, expressive crescent eyes, consistent body/head proportions. Cup in her left hand close to chest, chopsticks in right hand (viewer right), with one small visible strand of noodles. Warm steam stays close to cup; no detached giant clouds. + +One complete gentle eating loop, read left-to-right top-to-bottom: +1 relaxed idle holding cup with chopsticks lowered above cup, closed content smile. +2 looks at cup, chopsticks dip slightly. +3 lifts chopsticks, a short noodle strand rises. +4 lifts noodles toward mouth, elbow bends. +5 leans forward a little, mouth opens. +6 takes noodles into mouth, chopsticks close to lips, cheeks round. +7 slurps the remaining short noodle strand, cheeks gently puff. +8 closes mouth and chews, eyes happily closed. +9 happy satisfied little smile, chopsticks begin lowering. +10 shoulders relax, chopsticks lower toward cup. +11 nearly back to rest, hair settles. +12 EXACT same idle rest position, expression, cup/chopstick position as frame1. + +Keep the chair, feet, cup hand, lap blanket, costume, hair length and head size very stable across frames; actual changes occur in elbow, chopsticks, noodles, mouth, eyes and small upper-body lean. Do not stand up, jump, bounce chair, flip orientation or add extra arms/cups/chopsticks. Not twelve identical poses: clearly show the eating sequence. This sheet will be played without procedural limb movement. diff --git a/docs/concepts/yuru-camp/prompt-v1.txt b/docs/concepts/yuru-camp/prompt-v1.txt new file mode 100644 index 0000000..ff80fc5 --- /dev/null +++ b/docs/concepts/yuru-camp/prompt-v1.txt @@ -0,0 +1,15 @@ +Tool: built-in image_gen; transparent_background=true + +Use case: stylized-concept +Asset type: one approved-layout desktop keyboard skin concept for Miucs. +Input image: STRUCTURAL REFERENCE ONLY, the earlier Zelda keyboard skin. Keep its almost frontal flat 2D silhouette, the exact large shallow rectangular display above a full five-row ANSI keyboard, two short supporting stems, key arrangement and key widths, whole-device proportions, gently trapezoidal keyboard, and bold organic slightly broken ink outlines. Completely replace its theme. Do not retain any Zelda character, weapon, laser, ancient rune or sci-fi decoration. + +New theme: Laid-Back Camp / Yuru Camp (摇曳露营), a quiet cozy winter campsite with Nadeshiko Kagamihara and Rin Shima, rendered as charming recognizable chibi anime girls with simple clean hand-drawn cel shading. +Inside the upper wide shallow screen ONLY: +LEFT: Nadeshiko Kagamihara, long soft pink hair and characteristic pink bangs, cheerful round eyes and rosy cheeks, warm cream winter coat, coral-and-mint striped scarf. Seated on a small folding camping chair with a little blanket over her knees. Holds a small cup of instant noodles in one hand, uses chopsticks in the other to lift a short curl of noodles toward her smiling mouth. Tiny warm steam curls above the cup. Make her expressive, adorable, a little excited by the food. +RIGHT: Rin Shima, long dark navy-blue hair with visible side locks, pale knitted bobble beanie with subtle blue stripes, muted blue padded jacket and cozy scarf, seated comfortably on a folding camping chair. Holds a small camping mug in both hands near her chest, shoulders relaxed, quiet content expression with small dark eyes. A little gentle steam rises from mug. She faces slightly left toward the campfire and Nadeshiko. +CENTER: a SMALL orange campfire on a low metal fire pit, restrained warm flames and two tiny glowing embers, far smaller than either seated character, leaving generous breathing space. Behind the center, just a tiny pale ochre triangular camping tent, faint and understated, not a third focal point. One subtle curved ground line and soft shadows below chairs. Warm cream display background, no detailed mountains, forest or full landscape. All hats, hair, steam, chair legs and tent stay completely inside the display frame. Characters fill about 75% of screen's inner height, one at each side, fire about 30%. + +Keyboard below: preserve ALL five rows of the reference, exactly the same relative key sizes and layout. Cream/off-white ordinary keycaps, soft blue-gray modifier keys, muted navy frame and slightly lighter slate-blue deck. Replace glowing futuristic border marks with tiny understated red-and-cream tartan fabric corner tabs or stitched blanket motifs; keep most border plain. Two tiny rounded knitted mitten hands over the keyboard, one muted coral with cream cuff on left, one muted blue with cream cuff on right, matching cozy winter outfit colors and similar footprint to reference's typing hands. Two pressed keys have gentle warm apricot fill, not neon. Keep labels restrained and legible in dark ink. Do not invent extra keys or add a number pad. + +Composition: a single usable skin design, not a marketing poster, not a product photograph. Entire display-and-keyboard widget visible, reference's wide aspect ratio, clean margins. Actual transparent background outside the widget; upper display and keys are opaque cream. Friendly bold hand-inked contours, mostly flat cel colors and a little soft texture, high silhouette readability at small desktop size. No title, no logos, no explanatory annotations, no panels, no arrows, no watermarks, no cinematic scenery, no floating standalone battle above the device. One cohesive front-view concept image. Mood: warm food and tea by a little fire, peaceful companionship. diff --git a/docs/concepts/yuru-camp/props-draft.png b/docs/concepts/yuru-camp/props-draft.png new file mode 100644 index 0000000..785166c Binary files /dev/null and b/docs/concepts/yuru-camp/props-draft.png differ diff --git a/docs/concepts/yuru-camp/rin-sip-prompt.txt b/docs/concepts/yuru-camp/rin-sip-prompt.txt new file mode 100644 index 0000000..8491b7b --- /dev/null +++ b/docs/concepts/yuru-camp/rin-sip-prompt.txt @@ -0,0 +1,25 @@ +Tool: built-in image_gen; transparent_background=true +Reference: skin-concept-v1.png + +Use case: stylized-concept. Production animation keyframe atlas for the approved Yuru Camp desktop skin. +Use the reference only for the RIGHT character: Rin Shima seated in her chair with a blue blanket and mug. Draw ONLY Rin with her complete chair, blanket and cup, no Nadeshiko, fire, tent, keyboard or background. + +Exactly TWELVE consecutive frames in FOUR columns and THREE rows on a landscape 4:3 canvas. Actual transparent background. Equal square virtual cells with generous transparent gutters at least 15% on all sides. Character PLUS chair occupies only central 60% width and 65% height. Entire chair and all feet visible, no clipping or art near cell edges. Same character scale and camera every frame. Fixed chair feet floor baseline at local y80%, horizontal center x50%. No text, numbers, panel lines or watermarks. + +Chibi Rin Shima from Yuru Camp: long dark navy hair with characteristic side locks, calm small blue-dark eyes, cream knitted bobble hat with muted blue zigzag stripe, muted blue winter puffer jacket, mint/coral/cream striped scarf, navy blanket with simple cream geometric pattern over lap, warm brown folding chair. She holds a small plain gray camping mug in BOTH hands. Facing slightly LEFT, three-quarter view toward campfire offscreen. Match reference's hand-inked bold slightly organic outlines, soft flat colors and simple tasteful cel shading. She is quiet and reserved, no exaggerated grin or flailing. + +One unhurried gentle warm-drink loop, twelve keyframes in reading order: +1 idle, both hands cup mug at chest, calm eyes. +2 settles shoulders slightly, glances at mug. +3 elbows gently bend, mug starts lifting. +4 mug reaches just below lips, a tiny steam curl. +5 eyes half-close, takes a small sip. +6 sip held, mug tilted very slightly toward her. +7 lowers mug a little from lips. +8 tiny soft closed-eye content expression, exhales small close steam curl. +9 mug lowers toward chest. +10 eyes open, shoulders relax. +11 nearly at neutral, small hair follow-through. +12 EXACT same neutral pose and mug position as frame1. + +Motion very restrained; chair structure and feet stay still, costume and lap blanket geometry nearly identical. Real sequential drawn hand/elbow/mug/face changes, not moving copies of a sticker. Same hat and pompom, hair length and head size throughout. No steam covering her face, no extra cup or hands, no keyboard mittens included. diff --git a/docs/concepts/yuru-camp/runtime-eating.png b/docs/concepts/yuru-camp/runtime-eating.png new file mode 100644 index 0000000..e46ad56 Binary files /dev/null and b/docs/concepts/yuru-camp/runtime-eating.png differ diff --git a/docs/concepts/yuru-camp/runtime-idle.png b/docs/concepts/yuru-camp/runtime-idle.png new file mode 100644 index 0000000..d64962c Binary files /dev/null and b/docs/concepts/yuru-camp/runtime-idle.png differ diff --git a/docs/concepts/yuru-camp/runtime-preview.gif b/docs/concepts/yuru-camp/runtime-preview.gif new file mode 100644 index 0000000..93d3a18 Binary files /dev/null and b/docs/concepts/yuru-camp/runtime-preview.gif differ diff --git a/docs/concepts/yuru-camp/runtime-preview.png b/docs/concepts/yuru-camp/runtime-preview.png new file mode 100644 index 0000000..04659c7 Binary files /dev/null and b/docs/concepts/yuru-camp/runtime-preview.png differ diff --git a/docs/concepts/yuru-camp/runtime-preview.webp b/docs/concepts/yuru-camp/runtime-preview.webp new file mode 100644 index 0000000..57ccb6f Binary files /dev/null and b/docs/concepts/yuru-camp/runtime-preview.webp differ diff --git a/docs/concepts/yuru-camp/runtime-sipping.png b/docs/concepts/yuru-camp/runtime-sipping.png new file mode 100644 index 0000000..e030960 Binary files /dev/null and b/docs/concepts/yuru-camp/runtime-sipping.png differ diff --git a/docs/concepts/yuru-camp/skin-concept-v1.png b/docs/concepts/yuru-camp/skin-concept-v1.png new file mode 100644 index 0000000..03e248e Binary files /dev/null and b/docs/concepts/yuru-camp/skin-concept-v1.png differ diff --git a/docs/plugin-previews.md b/docs/plugin-previews.md index e84082f..72fefdd 100644 --- a/docs/plugin-previews.md +++ b/docs/plugin-previews.md @@ -9,7 +9,10 @@ GIF 使用同一组「音乐与打字」合成输入,正式 Lua worker 与 Ski | 牛来 | [查看](../plugins/niulai-keyboard/README.md) | | 平次与和叶 | [查看](../plugins/heiji-kazuha/README.md) | | 塞尔达 | [查看](../plugins/zelda-guardian/README.md) | +| 摇曳露营 | [查看](../plugins/yuru-camp/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),依次展示抚子吃面、凛轻啜和节拍篝火;同样使用合成输入与实际渲染器。 diff --git a/docs/validation/visualization-appearance-tests.json b/docs/validation/visualization-appearance-tests.json index 7e8b56f..e9051f5 100644 --- a/docs/validation/visualization-appearance-tests.json +++ b/docs/validation/visualization-appearance-tests.json @@ -57,7 +57,7 @@ "passed" : true }, { - "name" : "All five modes inherit transparency while adjustment overlays remain opaque", + "name" : "All six modes inherit transparency while adjustment overlays remain opaque", "passed" : true }, { diff --git a/docs/validation/yuru-camp.json b/docs/validation/yuru-camp.json new file mode 100644 index 0000000..416816e --- /dev/null +++ b/docs/validation/yuru-camp.json @@ -0,0 +1,17 @@ +{ + "checks": 156, + "nodeCount": 538, + "drawnFrames": { + "nadeshiko": 12, + "rin": 12, + "fire": 8, + "left-paw": 4, + "right-paw": 4 + }, + "scriptP95Ms": 0.036167, + "scriptMeanMs": 0.021664721144024516, + "fixedActorAnchors": true, + "constantPixelScale": true, + "inputs": "synthetic, production Lua worker", + "nativeDesktopAcceptance": false +} diff --git a/plugins/yuru-camp/ASSETS.md b/plugins/yuru-camp/ASSETS.md new file mode 100644 index 0000000..bd7b305 --- /dev/null +++ b/plugins/yuru-camp/ASSETS.md @@ -0,0 +1,15 @@ +# 资源与生成提示词 + +所有位图均使用内置 imagegen 生成,启用真实透明背景;以用户确认的[概念图](../../docs/concepts/yuru-camp/skin-concept-v1.png)为造型参考。 + +| 素材 | 内容 | 最终提示词 | +| --- | --- | --- | +| `assets/nadeshiko-eat.png` | 抚子与完整折叠椅,12 帧吃面,1448×1086 RGBA | [提示词](../../docs/concepts/yuru-camp/nadeshiko-eat-prompt.txt) | +| `assets/rin-sip.png` | 凛与完整折叠椅,12 帧喝茶,1448×1086 RGBA | [提示词](../../docs/concepts/yuru-camp/rin-sip-prompt.txt) | +| `assets/camp-props.png` | 前 8 格篝火,后 8 格双色手套,1254×1254 RGBA | [提示词](../../docs/concepts/yuru-camp/camp-props-prompt.txt) | + +`tools/plugins/register_camp_frames.py` 根据椅脚、火盆脚、袖口的可见像素读取固定锚点,输出 `frames.lua` 和 [注册数据](../../docs/concepts/yuru-camp/frame-registration.json)。不改写 PNG,不重新采样,不按每帧包围盒拉伸角色。模型将两只手套的最后一格画成相反方向,渲染时对这两格固定镜像并保持袖口位置;不借此合成压键动作。 + +`keys.lua`、`ink.lua` 与键盘输入处理复用现有插件结构。`assets/NotoSans-Regular.ttf` 和 `assets/OFL.txt` 沿用 SIL Open Font License 字体。 + +这是用户要求的非官方《摇曳露营》主题皮肤。抚子与志摩凛为现有 IP 角色,生成素材不表示获得原 IP 的商业授权。 diff --git a/plugins/yuru-camp/README.md b/plugins/yuru-camp/README.md new file mode 100644 index 0000000..7393170 --- /dev/null +++ b/plugins/yuru-camp/README.md @@ -0,0 +1,46 @@ +# 摇曳露营 + +![抚子吃面、凛捧杯与节拍篝火](../../docs/concepts/yuru-camp/runtime-preview.gif) + +Miucs API v1 独立皮肤,依据确认的概念图制作。沿用上方横屏与下方五行 ANSI 键盘:抚子坐在左侧吃杯面,凛在右侧捧热饮,中间放小篝火和淡色帐篷。奶油键帽、深蓝框、珊瑚格纹包角与左右针织手套。 + +## 动作与输入 + +- 新按键启动抚子的 12 帧吃面动作:挑面、靠近嘴、吸面、满足地眯眼、放低筷子,一次约 1.05 秒。左右分区和空格都可触发。长按重复不触发,密集打字只保留一个 200ms 内的近期动作,不打断当前这一口,不积累长队列。 +- 左右针织手套各 4 帧:悬停、弯曲、压下、回弹;跟随各自分区的最近按键。真实多键保持、修饰键、Caps Lock 与释放沿用原键盘逻辑,压下为暖杏色。 +- 每个可靠音乐拍点让篝火播放 8 帧轻跳,约 0.26–0.36 秒;高 BPM 时仅缩短播放时长,不缩放火焰。静音后保留低火静帧。 +- 首个拍点及之后每四拍尝试启动凛的 12 帧轻啜:捧杯、举杯、小口喝茶、回到胸前,一次约 1.6 秒。上一口没结束时跳过本次,不快速重复举杯。 +- 没有真实拍点就不追加音乐动作,也不根据音量或预测时钟假造节拍。音频失联复位凛和篝火;键盘失联复位抚子和手套;两类输入分别管理会话和序号,互不重置。隐藏恢复、过期事件、队列丢失与长时间卡顿不补播。 + +## 关键帧与素材 + +共 40 个内置 imagegen 绘制帧:抚子 12、凛 12、篝火 8、手套 8。动作、面条、杯子、蒸汽、表情和火焰变化已画在透明图集中。椅脚、火盆脚、手套袖口作为锚点,代码仅选帧与校准图集排版,人物不做程序变形或整体旋转。 + +两张手套回弹帧的拇指方向由静态镜像注册纠正;保持原始图像像素,不依赖每帧手工重画。手绘帧仍有轻微轮廓差异。键盘、格纹装饰和淡色帐篷采用原生路径绘制。 + +- [关键帧设计](../../docs/concepts/yuru-camp/keyframes.md) +- [抚子 12 帧](assets/nadeshiko-eat.png) · [凛 12 帧](assets/rin-sip.png) · [篝火与手套 16 帧](assets/camp-props.png) +- [资源来源与生成提示词](ASSETS.md) + +## 安装 + +将整个 `yuru-camp` 文件夹复制到用户插件目录,保持 `manifest.json` 位于第一层: + +- macOS:`~/Library/Application Support/Miucs/Plugins/yuru-camp/` +- Windows:`%LOCALAPPDATA%\Miucs\Plugins\yuru-camp\` + +应用菜单选择「重新扫描插件」→「可视化」→「摇曳露营」。源码构建会自动打包全部 `plugins/` 子目录;若已内置相同 ID,不要重复安装用户副本。 + +## 验证 + +```sh +python3 tools/plugins/register_camp_frames.py +python3 tools/plugins/validate.py plugins/yuru-camp +python3 tests/plugins/camp_replay.py +python3 tools/plugins/camp_preview.py +sh scripts/test-plugins.sh +zsh scripts/test-plugin-macos.sh +zsh scripts/test-appearance.sh +``` + +12 秒预览依次演示静止、仅打字、仅音乐、两者同时输入和停止。PNG、GIF、透明 WebP 都来自实际 Lua worker 与 Skia 渲染器;GIF 背景为便于观看的暖白色,应用背景透明。使用合成输入,不是桌面录屏,不包含真实系统权限、音频识别或 Windows 实机验收。 diff --git a/plugins/yuru-camp/assets/NotoSans-Regular.ttf b/plugins/yuru-camp/assets/NotoSans-Regular.ttf new file mode 100644 index 0000000..d552209 Binary files /dev/null and b/plugins/yuru-camp/assets/NotoSans-Regular.ttf differ diff --git a/plugins/yuru-camp/assets/OFL.txt b/plugins/yuru-camp/assets/OFL.txt new file mode 100644 index 0000000..c82d72e --- /dev/null +++ b/plugins/yuru-camp/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/yuru-camp/assets/camp-props.png b/plugins/yuru-camp/assets/camp-props.png new file mode 100644 index 0000000..785166c Binary files /dev/null and b/plugins/yuru-camp/assets/camp-props.png differ diff --git a/plugins/yuru-camp/assets/nadeshiko-eat.png b/plugins/yuru-camp/assets/nadeshiko-eat.png new file mode 100644 index 0000000..18498e3 Binary files /dev/null and b/plugins/yuru-camp/assets/nadeshiko-eat.png differ diff --git a/plugins/yuru-camp/assets/rin-sip.png b/plugins/yuru-camp/assets/rin-sip.png new file mode 100644 index 0000000..88a8f13 Binary files /dev/null and b/plugins/yuru-camp/assets/rin-sip.png differ diff --git a/plugins/yuru-camp/frames.lua b/plugins/yuru-camp/frames.lua new file mode 100644 index 0000000..497da5a --- /dev/null +++ b/plugins/yuru-camp/frames.lua @@ -0,0 +1,98 @@ +-- Original pixels registered at fixed contacts. See tools/plugins/register_camp_frames.py. +return { + nadeshiko={source='assets/nadeshiko-eat.png',scale=0.42,rects={ + {0,0,362,365}, + {362,0,362,365}, + {724,0,362,365}, + {1086,0,362,365}, + {0,365,362,345}, + {362,365,362,345}, + {724,365,362,345}, + {1086,365,362,345}, + {0,710,362,376}, + {362,710,362,376}, + {724,710,362,376}, + {1086,710,362,376}, + },offsets={ + {-200,-352}, + {-192,-352}, + {-184,-352}, + {-181,-352}, + {-200,-332}, + {-196,-332}, + {-185,-332}, + {-180,-332}, + {-202,-335}, + {-196,-335}, + {-188,-335}, + {-186,-335}, + },mirrored={false,false,false,false,false,false,false,false,false,false,false,false}}, + rin={source='assets/rin-sip.png',scale=0.45,rects={ + {0,0,362,375}, + {362,0,362,375}, + {724,0,362,375}, + {1086,0,362,375}, + {0,375,362,335}, + {362,375,362,335}, + {724,375,362,335}, + {1086,375,362,335}, + {0,710,362,376}, + {362,710,362,376}, + {724,710,362,376}, + {1086,710,362,376}, + },offsets={ + {-202,-356}, + {-196,-356}, + {-177,-356}, + {-167,-356}, + {-202,-316}, + {-190,-316}, + {-183,-315}, + {-174,-315}, + {-198,-319}, + {-192,-319}, + {-181,-319}, + {-167,-319}, + },mirrored={false,false,false,false,false,false,false,false,false,false,false,false}}, + fire={source='assets/camp-props.png',scale=0.32,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}, + },offsets={ + {-172,-310}, + {-163,-310}, + {-155,-310}, + {-147,-310}, + {-170,-287}, + {-164,-287}, + {-155,-287}, + {-150,-287}, + },mirrored={false,false,false,false,false,false,false,false}}, + leftHand={source='assets/camp-props.png',scale=0.2,rects={ + {0,640,314,295}, + {314,640,313,295}, + {627,640,313,295}, + {940,640,314,295}, + },offsets={ + {-172,-239}, + {-158,-243}, + {-138,-239}, + {-143,-239}, + },mirrored={false,false,false,true}}, + rightHand={source='assets/camp-props.png',scale=0.2,rects={ + {0,935,314,319}, + {314,935,313,319}, + {627,935,313,319}, + {940,935,314,319}, + },offsets={ + {-196,-220}, + {-174,-225}, + {-162,-220}, + {-120,-220}, + },mirrored={false,false,false,true}}, +} diff --git a/plugins/yuru-camp/ink.lua b/plugins/yuru-camp/ink.lua new file mode 100644 index 0000000..cbb44a5 --- /dev/null +++ b/plugins/yuru-camp/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='#46576C'}) + 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 + -- Small tartan fabric corners, drawn once like the existing key/frame outlines. + local function patch(id,x,y,w,h) + local children={{type='rect',id=id..'-cloth',width=w,height=h,fill='#C77F79'}} + for j=0,math.ceil(w/8) do + children[#children+1]={type='rect',id=id..'-v-'..j,x=j*8,y=0,width=3,height=h,fill='#F7DCC7B8'} + end + for j=0,math.ceil(h/8) do + children[#children+1]={type='rect',id=id..'-h-'..j,x=0,y=j*8,width=w,height=3,fill='#F7DCC794'} + end + add({type='group',id=id,x=x,y=y,clip={0,0,w,h},children=children}) + end + patch('cloth-tl-top',29,7,36,7);patch('cloth-tl-side',27,9,7,28) + patch('cloth-tr-top',735,7,36,7);patch('cloth-tr-side',766,9,7,28) + patch('cloth-bl',17,401,34,9);patch('cloth-br',749,401,34,9) + 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 '#F5C98E' 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}) + local orientation=a.mirrored[pose] and -1 or 1 + miucs.set(side..'-paw-art',{sourceRect=r,x=o[1]*a.scale*orientation,y=o[2]*a.scale, + width=r[3]*a.scale,height=r[4]*a.scale,scaleX=orientation}) + 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/yuru-camp/keys.lua b/plugins/yuru-camp/keys.lua new file mode 100644 index 0000000..92f61a5 --- /dev/null +++ b/plugins/yuru-camp/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/yuru-camp/main.lua b/plugins/yuru-camp/main.lua new file mode 100644 index 0000000..4ff6d9f --- /dev/null +++ b/plugins/yuru-camp/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=='yuru-camp';reset() end, + update=function(frame) + if active then S.update(M.update(state,frame,K.update(frame))) end + end, + appearanceChanged=function(a) miucs.set('yuru-camp',{opacity=a.opacity or 1}) end, + resize=function() end, + deactivate=function() active=false;reset() end, + dispose=function() active=false end +} diff --git a/plugins/yuru-camp/manifest.json b/plugins/yuru-camp/manifest.json new file mode 100644 index 0000000..1879102 --- /dev/null +++ b/plugins/yuru-camp/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "yuru-camp", + "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/yuru-camp/motion.lua b/plugins/yuru-camp/motion.lua new file mode 100644 index 0000000..a5dfedb --- /dev/null +++ b/plugins/yuru-camp/motion.lua @@ -0,0 +1,86 @@ +-- Original drawn poses, fixed world anchors. No procedural character movement. +local M={} +local times={ + eat={.04,.06,.08,.08,.08,.10,.13,.12,.08,.07,.06,.05}, + sip={.08,.10,.12,.14,.18,.18,.16,.18,.14,.12,.10,.10}, +} +local durations={} +for name,list in pairs(times) do + durations[name]=0;for _,t in ipairs(list) do durations[name]=durations[name]+t end +end +local function actor() return {age=10,frame=1,pending=0} end +local function pose(age,list) + local endTime=0 + for i,t in ipairs(list) do endTime=endTime+t;if age=durations.eat then d.age=0;d.pending=0 + else d.pending=s.time+.20 end + elseif d.age>=durations.eat and d.pending>0 then + if d.pending>=s.time then d.age=0 end + d.pending=0 + end + d.frame=pose(d.age,times.eat) +end +function M.update(s,frame,hits) + local dt=math.max(0,math.min(60,frame.deltaTime or 0)) + local now=frame.now or s.time+dt;s.time=s.time+dt + local input=frame.audio or {};local f=input.frame or {};local a=f.audio or {} + if s.audioSession~=input.sessionID then + s.audioSession=input.sessionID;s.audioCursor=0;resetAudio(s) + end + local through=f.sessionID==input.sessionID and (f.sequence or 0) or 0 + if dt>.5 then + s.nadeshiko=actor();resetAudio(s);s.audioCursor=math.max(s.audioCursor,through) + return s + end + eat(s,hits,dt) + s.rin.age=s.rin.age+dt;s.fireAge=s.fireAge+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 + if s.lastBeat and s.time-s.lastBeat>2.0 then s.beatCount=0 end + s.lastBeat=s.time;s.beatCount=s.beatCount+1;s.fireAge=0 + -- Finish eight drawn flame poses before the next beat even at 200 BPM. + s.fireDuration=math.min(.36,60/accepted.bpm*.85) + if (s.beatCount-1)%4==0 and s.rin.age>=durations.sip then s.rin.age=0 end + end + else + -- Audio loss affects only Rin/fire; keyboard animation is independent. + resetAudio(s) + end + s.audioCursor=math.max(s.audioCursor,through) + s.rin.frame=pose(s.rin.age,times.sip) + s.fireFrame=s.fireAge update,apply;size_t bytes=0,memory=0; diff --git a/tests/plugins/camp_replay.py b/tests/plugins/camp_replay.py new file mode 100644 index 0000000..1099621 --- /dev/null +++ b/tests/plugins/camp_replay.py @@ -0,0 +1,176 @@ +#!/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/yuru-camp')],stdin=subprocess.PIPE,stdout=subprocess.PIPE) + self.scene={};self.version=0;self.index=0;self.timings=[] + self.seen={name:set() for name in ('nadeshiko','rin','fire','left-paw','right-paw')} + self.read() + self.rest={name:tuple(self.scene[name+'-art']['sourceRect']) for name in ('nadeshiko','rin','fire')} + self.fixed={name:copy.deepcopy(self.scene[name]) for name in ('nadeshiko','rin','fire')} + 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={'nadeshiko':.42,'rin':.45,'fire':.32}[name] + 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':'yuru-camp','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,name='nadeshiko'): return tuple(self.scene[name+'-art']['sourceRect'])!=self.rest[name] + 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']) + h.advance(120,audio=False) + check(not any(h.moving(n) for n in h.rest),'idle has no invented eating, sipping or flicker') + for key in ('a','j','space','returnKey','digit1'): + h.activate();h.tick(key=key,audio=False);h.advance(10,audio=False) + check(h.moving(),key+' triggers eating without music') + check(not h.moving('rin') and not h.moving('fire'),'keyboard leaves music actors alone') + h.advance(90,audio=False);check(not h.moving(),'one press finishes one complete bite') + h.activate();h.tick(key='a',held=('a',),audio=False);h.advance(12,held=('a',),audio=False) + check(h.scene['key-a-fill']['fill']=='#F5C98E','held key lights warm apricot') + for _ in range(120): h.tick(key='a',kind='repeated',held=('a',),audio=False) + check(not h.moving(),'held repeat cannot create endless eating') + h.tick(key='a',kind='released',audio=False);h.advance(20,audio=False) + check(h.scene['key-a-fill']['fill']=='#FFF9EE','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);h.advance(12,held=('a','j'),audio=False) + check(h.moving() and all(h.scene['key-'+k+'-fill']['fill']=='#F5C98E' for k in ('a','j')),'chord keeps both keys lit, one eating action') + 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'), + ]: + h.activate();f=h.sample(key='a',audio=False);mutate(f);h.post('update',f);h.advance(10,audio=False) + check(not h.moving(),label+' cannot trigger eating') + 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.moving(),'modifier works') + h.activate();f=h.sample(key='a',audio=False);h.post('update',f);h.advance(100,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(not h.moving(),'duplicate press ignored') + h.activate() + for _ in range(80): + f=h.sample(audio=False);f['keyboard']['pressesPerSecond']=200;h.post('update',f) + check(not h.moving(),'typing summary alone cannot invent presses') + + h.activate();h.tick(beat=True);h.advance(9) + check(h.moving('rin') and h.moving('fire'),'first reliable beat triggers drawn sip and flame') + check(not h.moving(),'music cannot make Nadeshiko eat') + h.advance(100) + check(not h.moving('rin') and not h.moving('fire'),'without more events music actors settle') + for _ in range(100): + h.tick();check(not h.moving('fire'),'no predicted or fabricated beats') + h.activate() + for i in range(120): h.tick(beat=i%30==0) + check(not h.moving('rin'),'beats two through four do not restart the first sip') + h.tick(beat=True);h.advance(9) + check(h.moving('rin'),'fifth beat starts the next relaxed sip') + 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(bpm=250),'invalid tempo'), + (lambda f:f['audio']['beats'][0].update(receivedAt=f['now']-.5),'expired 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(9) + check(not h.moving('rin') and not h.moving('fire'),label+' cannot animate music actors') + h.activate();f=h.sample(beat=True);h.post('update',f);h.advance(100) + # Refresh host and PCM clocks but retain the original sequence. + 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(9) + check(not h.moving('rin') and not h.moving('fire'),'duplicate beat ignored') + h.activate();h.tick(key='a',beat=True);h.advance(9) + f=h.sample();f['audio'].update(state='recovering',frame=None,frameAge=None,beats=[]);h.post('update',f) + check(h.moving() and not h.moving('rin') and not h.moving('fire'),'audio disconnect affects only music actors') + f=h.sample(beat=True);f['keyboard']['state']='secureInput';h.post('update',f);h.advance(9) + check(not h.moving() and h.moving('rin') and h.moving('fire'),'keyboard loss does not stop music') + 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(9): + f=h.sample();f['audio']['sessionID']='new';f['audio']['frame'].update(sessionID='new',sequence=i+2);h.post('update',f) + check(h.moving('rin') and h.moving('fire'),'fresh low-sequence beat works after audio reconnect') + check(h.scene['nadeshiko']==h.fixed['nadeshiko'],'audio reconnect preserves character placement') + h.tick(key='j',beat=True,deltaTime=2) + check(not any(h.moving(n) for n in h.rest),'long stall settles all actors') + + h.activate() + for i in range(1800): + 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) + check(len(h.scene)==topology,'30 seconds of input keeps fixed node count') + check({n:len(h.seen[n]) for n in ('nadeshiko','rin','fire')}=={'nadeshiko':12,'rin':12,'fire':8},'all 32 scene frames play') + check(all(len(h.seen[n])==4 for n in ('left-paw','right-paw')),'all eight mitten frames play') + # The final rebound frame has a registered orientation correction only. + h.activate();h.tick(key='a',held=('a',),audio=False);h.advance(8,held=('a',),audio=False) + h.tick(key='a',kind='released',audio=False) + check(h.scene['left-paw-art']['scaleX']==-1,'rebound thumb remains on the same side') + h.advance(30,audio=False);check(h.scene['left-paw-art']['scaleX']==1,'rest restores normal source orientation') + for i in range(240): h.tick(key='a' if i%2 else 'j',audio=False) + h.advance(150,audio=False);check(not h.moving(),'fast typing cannot accumulate bites') + check(h.scene['monitor-ink-1']['path']==ink,'static keyboard outline never jitters') + h.post('appearanceChanged',{'opacity':.4,'ink':'#000000'}) + check(h.scene['yuru-camp']['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 plugin ignores input') + h.activate();h.advance(60,audio=False);check(not any(h.moving(n) for n in h.rest),'reactivation starts settled') + 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,inputs='synthetic, production Lua worker',nativeDesktopAcceptance=False) + (ROOT/'docs/validation/yuru-camp.json').write_text(json.dumps(report,indent=2)+'\n') + print(json.dumps(report,indent=2)) +finally: h.close() diff --git a/tests/plugins/core_tests.cpp b/tests/plugins/core_tests.cpp index 9288eba..7df32ba 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"}; + const std::set builtinIDs={"pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha","zelda-guardian","yuru-camp"}; 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 896e877..8c08f95 100644 --- a/tests/plugins/mac_reload.swift +++ b/tests/plugins/mac_reload.swift @@ -52,11 +52,12 @@ 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"]) + let ids=Set(["pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha","zelda-guardian","yuru-camp"]) precondition(ids.isSubset(of:Set(registry.descriptors.map(\.id)))) precondition(host.packages.isEmpty,"Tray must not eagerly launch a plugin") - precondition(registry.descriptors.prefix(5).map(\.id)==["pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha","zelda-guardian"]) + 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=="摇曳露营") 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 3c40186..3ccadf6 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'][int(elapsed//10)%5] + mode=['pixel-keyboard','muffin-puppies','niulai-keyboard','heiji-kazuha','zelda-guardian','yuru-camp'][int(elapsed//10)%6] 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 798b399..8c4d1ad 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"}) { + for(auto id:{"pixel-keyboard","muffin-puppies","niulai-keyboard","heiji-kazuha","zelda-guardian","yuru-camp"}) { 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/camp_preview.py b/tools/plugins/camp_preview.py new file mode 100644 index 0000000..0bb7f58 --- /dev/null +++ b/tools/plugins/camp_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/yuru-camp') +args=p.parse_args();args.output.mkdir(parents=True,exist_ok=True) +b=Bridge(ROOT/'plugins/yuru-camp') +frames=[] + +def render(width=800,height=460): + raw=b.render('yuru-camp',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':'yuru-camp','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,107,280,556): + name={30:'runtime-idle.png',107:'runtime-eating.png',280:'runtime-sipping.png',556:'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_camp_frames.py b/tools/plugins/register_camp_frames.py new file mode 100644 index 0000000..c389b82 --- /dev/null +++ b/tools/plugins/register_camp_frames.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Register model-drawn chair, fire-pit and cuff contacts; never rewrite PNGs.""" +import json +from pathlib import Path +from PIL import Image + +ROOT=Path(__file__).resolve().parents[2] +PACKAGE=ROOT/'plugins/yuru-camp' + +def cells(width,ys,columns=4): + xs=[round(i*width/columns) for i in range(columns+1)] + 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(columns)] + +def register(source,regions,scale,mirrored=()): + im=Image.open(PACKAGE/source) + assert im.mode=='RGBA' and im.getextrema()[3][0]==0 + result=dict(source=source,scale=scale,rects=regions,offsets=[],contacts=[],bounds=[],mirrored=[]) + for i,(x,y,w,h) in enumerate(regions): + assert x>=0 and y>=0 and x+w<=im.width and y+h<=im.height + alpha=im.crop((x,y,x+w,y+h)).getchannel('A').point(lambda a:255 if a>96 else 0) + bounds=alpha.getbbox();assert bounds, (source,i) + l,t,r,b=bounds + # Guard against a crop through visible art or a neighboring cell. + assert l>1 and t>1 and r