feat(examples): 新增 v2 实机演示皮肤「夜樱」 (#1018)
* feat(examples): 新增 v2 实机演示皮肤「夜樱」 基于 canvas 的 schema_version 2 皮肤,用上九宫格面板(四角花纹不随拉伸变形)、面板后的月亮挂件、循环 APNG 花瓣与循环 GIF 星星、content_insets_dip、outer_insets_dip 和 line_gap_dip。左上角不放挂件,免得挡住预编辑文字。图片由 tools/gen_sakura_night.py 程序生成,随本目录以 MIT 授权,重新运行得到同样的文件。 * feat(examples): 夜樱的悬浮工具栏改用候选窗的紫夜配色
This commit is contained in:
1 parent
9c45526cd2
commit
cbb17c5763
7 files changed
+328
-5
No files matched your search
@@ -38,19 +38,27 @@ examples/skin-examples/
|
||||
│ │ └─ assets/
|
||||
│ │ ├─ character.png # 卡片上方的装饰图
|
||||
│ │ └─ background.png # 卡片背景图
|
||||
│ └─ canvas-demo/ # schema_version 2 + canvas 基底的全贴图样例
|
||||
│ ├─ canvas-demo/ # schema_version 2 + canvas 基底的全贴图样例
|
||||
│ │ ├─ skin.toml
|
||||
│ │ └─ assets/
|
||||
│ │ ├─ panel.png # 九宫格面板(2 倍图)
|
||||
│ │ ├─ leaf.png # 面板下层挂件
|
||||
│ │ └─ sparkle.apng # 面板上层的循环 APNG 挂件
|
||||
│ └─ sakura-night/ # v2 实机演示「夜樱」:面板后的月亮、APNG 花瓣、GIF 星星
|
||||
│ ├─ skin.toml
|
||||
│ └─ assets/
|
||||
│ ├─ panel.png # 九宫格面板(2 倍图)
|
||||
│ ├─ leaf.png # 面板下层挂件
|
||||
│ └─ sparkle.apng # 面板上层的循环 APNG 挂件
|
||||
│ ├─ panel.png # 九宫格面板(2 倍图,四角小樱花不随拉伸变形)
|
||||
│ ├─ moon.png # 面板下层挂件
|
||||
│ ├─ petals.apng # 面板上层的循环 APNG 挂件
|
||||
│ └─ stars.gif # 面板上层的循环 GIF 挂件
|
||||
├─ schema/ # manifest 字段说明
|
||||
├─ tools/ # 生成样例图片的脚本(需要 Pillow)
|
||||
├─ tests/ # CI 用的 manifest 与安装脚本测试
|
||||
├─ preview.html # 本地预览
|
||||
└─ install.ps1 # 样例安装脚本
|
||||
```
|
||||
|
||||
只有 `skins/<id>/` 会被安装,`preview.html`、`schema/`、`tests/` 和安装脚本都只是制作工具。
|
||||
只有 `skins/<id>/` 会被安装,`preview.html`、`schema/`、`tools/`、`tests/` 和安装脚本都只是制作工具。
|
||||
|
||||
## 样例皮肤能演示什么
|
||||
|
||||
@@ -138,6 +146,20 @@ z_index = 2 # z_index < 1 画在面板之下,其余画在
|
||||
- 动图在系统关闭「动画效果」时只显示第一帧。动图解码后合计不超过 64 MB、最多 1024 帧,超出时只显示第一帧。
|
||||
- 样例里的三张图都是程序生成的几何图形,随本目录以 MIT 授权,可以直接改用。
|
||||
|
||||
`sakura-night`(夜樱)是一个更完整的实机演示,把 v2 的能力都用上了:
|
||||
|
||||
- 紫夜玻璃九宫格面板,四角的小樱花落在 24 像素切片内,候选框怎么伸缩都不变形。
|
||||
- 月亮挂在右上角、`z_index = -1` 画在面板之后,只露出伸出面板的那一截。
|
||||
- 右侧飘落的花瓣是 24 帧 × 70 ms 的循环 APNG,左下闪烁的星星是 8 帧 × 150 ms 的循环 GIF。
|
||||
- 用 `content_insets_dip` 让文字避开边框和四角花纹,用 `outer_insets_dip` 给伸出去的挂件留出窗口空间,用 `line_gap_dip` 拉开预编辑行与候选。
|
||||
- 左上角刻意不放挂件:预编辑文字从那里开始,挂件压上去会挡字。
|
||||
|
||||
它的图片都由 `tools/gen_sakura_night.py` 生成(需要 Pillow),重新运行会得到同样的文件:
|
||||
|
||||
```powershell
|
||||
python tools/gen_sakura_night.py skins/sakura-night
|
||||
```
|
||||
|
||||
## 制作约束
|
||||
|
||||
- 只用 `skin.toml` 和本地图片,不支持自定义 JavaScript,也不再读取 `cand.css` 或工具栏 CSS。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,120 @@
|
||||
# 水杉输入法 schema_version 2 实机演示皮肤「夜樱」。
|
||||
# 用到 v2 的全部能力:canvas 基底、九宫格面板、面板后/前两层挂件、APNG 与 GIF 动图、内容内边距、外留白、行距。
|
||||
# 所有图片由 tools/gen_sakura_night.py 程序生成,随本目录以 MIT 授权;都是 2 倍图(像素尺寸是下面 *_dip 的两倍)。
|
||||
|
||||
schema_version = 2
|
||||
id = "sakura-night"
|
||||
name = "夜樱 · v2 演示"
|
||||
version = "1.0.0"
|
||||
author = "Metasequoia IME contributors"
|
||||
description = "v2 全贴图演示:紫夜玻璃九宫格面板、面板后的月亮、右侧飘落的花瓣(APNG)、左下闪烁的星星(GIF)。"
|
||||
base = "canvas"
|
||||
|
||||
[supports]
|
||||
layouts = ["horizontal", "vertical"]
|
||||
themes = ["dark", "light"]
|
||||
|
||||
[candidate_window]
|
||||
min_width_dip = 220
|
||||
item_corner_radius_dip = 6
|
||||
line_gap_dip = 6 # 预编辑行与候选之间
|
||||
|
||||
# 96×96 的 2 倍面板图:切片 24 像素,边框画 12 DIP。四角的小樱花在切片之内,面板怎么拉伸都不变形。
|
||||
[candidate_window.border_image]
|
||||
source = "assets/panel.png"
|
||||
slice_dip = [24]
|
||||
width_dip = [12]
|
||||
fill = true
|
||||
|
||||
# 内容离面板外缘的距离;左右多留一点,避开四角的小樱花。
|
||||
[candidate_window.content_insets_dip]
|
||||
top = 12
|
||||
right = 16
|
||||
bottom = 12
|
||||
left = 18
|
||||
|
||||
# 挂件伸出面板的部分需要的窗口留白。
|
||||
[candidate_window.outer_insets_dip]
|
||||
top = 30
|
||||
right = 36
|
||||
bottom = 22
|
||||
left = 12
|
||||
|
||||
# 月亮:z_index < 1,画在面板之后,只露出右上角伸出去的那一截。
|
||||
[[candidate_window.decorations]]
|
||||
image = "assets/moon.png"
|
||||
anchor = "top-right"
|
||||
offset_x_dip = 30
|
||||
offset_y_dip = -30
|
||||
width_dip = 72
|
||||
height_dip = 72
|
||||
z_index = -1
|
||||
|
||||
# 飘落的花瓣:24 帧 × 70 ms 循环的 APNG,沿右侧边框落下。
|
||||
[[candidate_window.decorations]]
|
||||
image = "assets/petals.apng"
|
||||
anchor = "bottom-right"
|
||||
offset_x_dip = 36
|
||||
offset_y_dip = 22
|
||||
width_dip = 48
|
||||
height_dip = 80
|
||||
z_index = 3
|
||||
|
||||
# 闪烁的星星:8 帧 × 150 ms 循环的 GIF。
|
||||
[[candidate_window.decorations]]
|
||||
image = "assets/stars.gif"
|
||||
anchor = "bottom-left"
|
||||
offset_x_dip = -12
|
||||
offset_y_dip = 16
|
||||
width_dip = 40
|
||||
height_dip = 20
|
||||
z_index = 1
|
||||
|
||||
# 面板是深色的,深浅两套主题都用浅色文字。
|
||||
[candidate.dark]
|
||||
accent = "#ff8fb8"
|
||||
selected = "rgba(255, 143, 184, 0.22)"
|
||||
hover = "rgba(255, 143, 184, 0.12)"
|
||||
text = "#f5ecff"
|
||||
number = "rgba(245, 236, 255, 0.58)"
|
||||
translation = "#c9b8ff"
|
||||
preedit_text = "#ffc4dc"
|
||||
selected_text = "#ffffff"
|
||||
selected_bar = "#ffd27a"
|
||||
|
||||
[candidate.light]
|
||||
accent = "#ff8fb8"
|
||||
selected = "rgba(255, 143, 184, 0.24)"
|
||||
hover = "rgba(255, 143, 184, 0.14)"
|
||||
text = "#f5ecff"
|
||||
number = "rgba(245, 236, 255, 0.60)"
|
||||
translation = "#c9b8ff"
|
||||
preedit_text = "#ffc4dc"
|
||||
selected_text = "#ffffff"
|
||||
selected_bar = "#ffd27a"
|
||||
|
||||
# 悬浮工具栏跟候选窗同一套紫夜配色:底色取面板的紫,边框取面板的粉边,拖动条用主题粉,图标同候选文字。
|
||||
# 面板在深浅两套主题下都是深色,工具栏也一样。
|
||||
[toolbar]
|
||||
corner_radius_dip = 10 # 与面板圆角一致(面板图 20 像素圆角,2 倍图)
|
||||
|
||||
[toolbar.dark]
|
||||
background = "#211838"
|
||||
border = "rgba(255, 170, 205, 0.55)"
|
||||
handle = "#ff8fb8"
|
||||
divider = "rgba(255, 143, 184, 0.28)"
|
||||
icon = "#f5ecff"
|
||||
hover = "rgba(255, 143, 184, 0.18)"
|
||||
|
||||
[toolbar.light]
|
||||
background = "#211838"
|
||||
border = "rgba(255, 170, 205, 0.55)"
|
||||
handle = "#ff8fb8"
|
||||
divider = "rgba(255, 143, 184, 0.28)"
|
||||
icon = "#f5ecff"
|
||||
hover = "rgba(255, 143, 184, 0.18)"
|
||||
|
||||
[license]
|
||||
code = "MIT"
|
||||
assets = "MIT"
|
||||
source = "Procedurally generated by the Metasequoia IME contributors."
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Generate the "sakura-night" schema_version 2 demo skin for Metasequoia IME.
|
||||
|
||||
All artwork is procedural (Pillow), drawn at 4x and downsampled for anti-aliasing.
|
||||
Every bitmap is a 2x asset: the manifest's *_dip sizes are half the pixel sizes.
|
||||
|
||||
Requires Pillow (pip install pillow). From examples/skin-examples:
|
||||
python tools/gen_sakura_night.py skins/sakura-night
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
SS = 4 # supersampling factor
|
||||
|
||||
|
||||
def canvas(w, h):
|
||||
return Image.new("RGBA", (w * SS, h * SS), (0, 0, 0, 0))
|
||||
|
||||
|
||||
def finish(img):
|
||||
w, h = img.size
|
||||
return img.resize((w // SS, h // SS), Image.LANCZOS)
|
||||
|
||||
|
||||
def lerp(a, b, t):
|
||||
return tuple(round(a[i] + (b[i] - a[i]) * t) for i in range(len(a)))
|
||||
|
||||
|
||||
def sakura_flower(draw, cx, cy, r, petal, center, rot=0.0):
|
||||
"""Five notched petals around a small center, in supersampled coordinates."""
|
||||
for k in range(5):
|
||||
ang = rot + k * 2 * math.pi / 5
|
||||
px = cx + math.cos(ang) * r * 0.55
|
||||
py = cy + math.sin(ang) * r * 0.55
|
||||
pr = r * 0.48
|
||||
draw.ellipse([px - pr, py - pr, px + pr, py + pr], fill=petal)
|
||||
# notch at the petal tip
|
||||
nx = cx + math.cos(ang) * r * 1.02
|
||||
ny = cy + math.sin(ang) * r * 1.02
|
||||
nr = r * 0.14
|
||||
draw.ellipse([nx - nr, ny - nr, nx + nr, ny + nr], fill=(0, 0, 0, 0))
|
||||
cr = r * 0.22
|
||||
draw.ellipse([cx - cr, cy - cr, cx + cr, cy + cr], fill=center)
|
||||
|
||||
|
||||
def panel(out):
|
||||
"""96x96 nine-slice panel; slices are 24 px (12 DIP), corner flowers live inside the slices."""
|
||||
W = H = 96
|
||||
img = canvas(W, H)
|
||||
s = SS
|
||||
# vertical gradient fill clipped to a rounded rectangle
|
||||
grad = Image.new("RGBA", img.size)
|
||||
gd = ImageDraw.Draw(grad)
|
||||
top, bottom = (34, 22, 58, 240), (22, 16, 44, 240)
|
||||
for y in range(img.size[1]):
|
||||
gd.line([(0, y), (img.size[0], y)], fill=lerp(top, bottom, y / img.size[1]))
|
||||
mask = Image.new("L", img.size, 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle([0, 0, W * s - 1, H * s - 1], radius=20 * s, fill=255)
|
||||
img.paste(grad, (0, 0), mask)
|
||||
d = ImageDraw.Draw(img)
|
||||
# outer rim and a faint inner highlight
|
||||
d.rounded_rectangle([1 * s, 1 * s, W * s - 1 * s - 1, H * s - 1 * s - 1], radius=19 * s,
|
||||
outline=(255, 170, 205, 150), width=2 * s)
|
||||
d.rounded_rectangle([4 * s, 4 * s, W * s - 4 * s - 1, H * s - 4 * s - 1], radius=16 * s,
|
||||
outline=(255, 255, 255, 28), width=1 * s)
|
||||
# corner flowers: entirely within the 24 px corner slices, so they never stretch
|
||||
gold, pink = (255, 214, 140, 235), (255, 160, 200, 235)
|
||||
for (cx, cy, col, rot) in [(15, 15, gold, -0.3), (W - 15, 15, pink, 0.2),
|
||||
(15, H - 15, pink, 0.5), (W - 15, H - 15, gold, 0.9)]:
|
||||
sakura_flower(d, cx * s, cy * s, 6.5 * s, col, (255, 245, 220, 255), rot)
|
||||
finish(img).save(out / "panel.png", optimize=True)
|
||||
|
||||
|
||||
def moon(out):
|
||||
"""144x144 moon with a soft halo; drawn behind the panel (z_index < 1)."""
|
||||
W = H = 144
|
||||
s = SS
|
||||
halo = canvas(W, H)
|
||||
hd = ImageDraw.Draw(halo)
|
||||
c = W * s / 2
|
||||
for i in range(28, 0, -1):
|
||||
r = (40 + i * 1.1) * s
|
||||
hd.ellipse([c - r, c - r, c + r, c + r], fill=(255, 210, 120, int(10 + (28 - i) * 2.2)))
|
||||
body = canvas(W, H)
|
||||
bd = ImageDraw.Draw(body)
|
||||
r = 40 * s
|
||||
bd.ellipse([c - r, c - r, c + r, c + r], fill=(255, 226, 140, 255))
|
||||
for (dx, dy, rr, a) in [(-12, -8, 8, 70), (10, 12, 11, 60), (14, -15, 5, 70), (-8, 17, 5, 55)]:
|
||||
bd.ellipse([c + dx * s - rr * s, c + dy * s - rr * s, c + dx * s + rr * s, c + dy * s + rr * s],
|
||||
fill=(214, 168, 90, a))
|
||||
img = Image.alpha_composite(halo.filter(ImageFilter.GaussianBlur(6 * s)), body)
|
||||
finish(img).save(out / "moon.png", optimize=True)
|
||||
|
||||
|
||||
def petal_sprite(size, color):
|
||||
s = SS
|
||||
p = Image.new("RGBA", (size * s, size * s), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(p)
|
||||
d.ellipse([size * 0.18 * s, 0, size * 0.82 * s, size * s - 1], fill=color)
|
||||
d.ellipse([size * 0.42 * s, -size * 0.12 * s, size * 0.58 * s, size * 0.12 * s], fill=(0, 0, 0, 0))
|
||||
return p
|
||||
|
||||
|
||||
def petals(out):
|
||||
"""96x160, 24 frames x 70 ms looping APNG of falling petals (front layer)."""
|
||||
W, H, N = 96, 160, 24
|
||||
rnd = random.Random(11)
|
||||
specs = []
|
||||
for _ in range(10):
|
||||
specs.append(dict(x0=rnd.uniform(10, W - 14), y0=rnd.uniform(0, H), size=rnd.randint(12, 18),
|
||||
sway=rnd.uniform(6, 14), phase=rnd.uniform(0, 2 * math.pi),
|
||||
spin=rnd.choice([-1, 1]) * rnd.uniform(0.6, 1.4),
|
||||
color=lerp((255, 190, 215, 230), (255, 140, 185, 230), rnd.random())))
|
||||
frames = []
|
||||
for f in range(N):
|
||||
t = f / N # one full fall per loop keeps the animation seamless
|
||||
img = canvas(W, H)
|
||||
for sp in specs:
|
||||
y = (sp["y0"] + t * (H + 20)) % (H + 20) - 20
|
||||
x = sp["x0"] + math.sin(sp["phase"] + t * 2 * math.pi) * sp["sway"]
|
||||
angle = math.degrees(sp["phase"] + sp["spin"] * t * 2 * math.pi)
|
||||
sprite = petal_sprite(sp["size"], sp["color"]).rotate(angle, resample=Image.BICUBIC, expand=True)
|
||||
fade = min(1.0, (y + 20) / 30, (H - y) / 30)
|
||||
if fade <= 0:
|
||||
continue
|
||||
if fade < 1:
|
||||
a = sprite.getchannel("A").point(lambda v: int(v * fade))
|
||||
sprite.putalpha(a)
|
||||
img.alpha_composite(sprite, (int(x * SS - sprite.size[0] / 2), int(y * SS - sprite.size[1] / 2)))
|
||||
frames.append(finish(img))
|
||||
frames[0].save(out / "petals.apng", format="PNG", save_all=True, append_images=frames[1:], duration=70,
|
||||
loop=0, disposal=1, blend=0, optimize=True)
|
||||
|
||||
|
||||
def stars(out):
|
||||
"""80x40, 8 frames x 150 ms looping GIF of twinkling stars (front layer, shows GIF support)."""
|
||||
W, H, N = 80, 40, 8
|
||||
rnd = random.Random(3)
|
||||
pts = [(rnd.uniform(6, W - 6), rnd.uniform(6, H - 6), rnd.uniform(2.5, 5), rnd.uniform(0, 2 * math.pi))
|
||||
for _ in range(6)]
|
||||
frames = []
|
||||
for f in range(N):
|
||||
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
for (x, y, r, ph) in pts:
|
||||
k = 0.35 + 0.65 * (0.5 + 0.5 * math.sin(ph + f * 2 * math.pi / N))
|
||||
rr = r * k
|
||||
# four-point star
|
||||
d.polygon([(x, y - rr * 2), (x + rr * 0.45, y - rr * 0.45), (x + rr * 2, y), (x + rr * 0.45, y + rr * 0.45),
|
||||
(x, y + rr * 2), (x - rr * 0.45, y + rr * 0.45), (x - rr * 2, y), (x - rr * 0.45, y - rr * 0.45)],
|
||||
fill=(255, 226, 150, 255))
|
||||
# GIF has 1-bit transparency: threshold alpha, then quantize with a reserved transparent index
|
||||
alpha = img.getchannel("A").point(lambda v: 255 if v >= 128 else 0)
|
||||
rgb = Image.new("RGB", (W, H), (0, 0, 0))
|
||||
rgb.paste(img.convert("RGB"), mask=alpha)
|
||||
p = rgb.quantize(colors=255)
|
||||
p.paste(255, mask=alpha.point(lambda v: 255 if v == 0 else 0))
|
||||
frames.append(p)
|
||||
frames[0].save(out / "stars.gif", save_all=True, append_images=frames[1:], duration=150, loop=0,
|
||||
transparency=255, disposal=2, optimize=False)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
raise SystemExit(__doc__)
|
||||
out = Path(sys.argv[1]) / "assets"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
panel(out)
|
||||
moon(out)
|
||||
petals(out)
|
||||
stars(out)
|
||||
for f in sorted(out.iterdir()):
|
||||
print(f"{f.name}\t{f.stat().st_size} bytes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user