Update watch face: modular refactor + OWM/Solar/background + codegen tools

- Split render into FieldTable/Fields/Icons/Layout/Settings modules
- Add Fenix8V3Background, Owm (weather), Solar modules
- Add tools/ generators (gen_fields/gen_icons/gen_themes/gen_preview_svg)
- Tune README/manifest/strings/settings
This commit is contained in:
2026-09-10 07:10:55 +08:00
parent 52346bdd09
commit 4d96ab0912
23 changed files with 1549 additions and 344 deletions

View File

@@ -3,24 +3,34 @@ import Toybox.Graphics;
import Toybox.WatchUi;
import Layout;
// The glyphs that sit above the two top data slots, plus the battery and the
// bluetooth rune.
// ============================================================================
// 图标
// ----------------------------------------------------------------------------
// 顶部两个数据位上方的图标,加上电池和蓝牙符号。
//
// The comps contain three icons -- sunrise, weather, battery. The first two
// are traced outlines whose finest features are two or three design pixels
// across; rebuilding them from circles and lines looked right at 454 px and
// turned to mush at 280. They ship instead as bitmaps rasterised from the
// design paths once per screen width (tools/gen_icons.py) and are tinted with
// the theme's accent at draw time.
// 设计稿里只有三个图标:日出、天气、电池。
//
// Everything else -- steps, calories, stairs, heart -- has no counterpart in
// the comps, so those stay as primitives sized off the same design canvas.
// 前两个是位图描出来的矢量轮廓,最细的特征只有 23 个设计像素宽。一开始我用
// 圆、线段、多边形去「重画」它们,在 454px 上看着还行,一到 280pxEnduro 3
// 就糊成一团 —— 因为 s=0.56 时那些细节不足 1 个物理像素。
// 中间还试过做一版「低细节」图元,结果更糟:日出变成三条横杠。
//
// 现在的做法是:用 tools/gen_icons.py 把**设计稿的路径**按每种屏宽各光栅化一
// 张位图,运行时用 drawBitmap2(:tintColor) 按主题色染色。这样每台设备拿到的
// 都是为它的像素网格专门渲染的图。
//
// 其余图标(步数、卡路里、楼梯、心形…)设计稿里根本没有,所以保持图元绘制,
// 尺寸同样基于 500 设计画布。
// ============================================================================
module Icons {
// 三张位图在 onLayout 时一次性加载loadResource 开销不小,不能放 onUpdate
var mSunrise = null;
var mSunset = null;
var mWeather = null;
// 加载图标位图。资源按屏宽分目录monkey.jungle 里为每台设备指定了
// resources-icons-<屏宽>,所以这里拿到的自动就是对的尺寸。
function load() as Void {
mSunrise = WatchUi.loadResource(Rez.Drawables.IconSunrise);
mSunset = WatchUi.loadResource(Rez.Drawables.IconSunset);
@@ -29,8 +39,9 @@ module Icons {
function ready() as Boolean { return mSunrise != null; }
// drawBitmap2 refuses a palettised source, which is why the generated
// drawables carry packingFormat="png" to keep their alpha channel.
// ⚠️ drawBitmap2 的 :tintColor 拒绝**调色板化**的位图,运行时会抛
// "Source must not use a color palette"。所以生成的 drawables.xml 里每个
// bitmap 都带 packingFormat="png",保住 alpha 通道不被压成调色板。
function blit(dc as Graphics.Dc, bmp, cxDesign as Numeric, col as Number) as Void {
if (bmp == null) { return; }
dc.drawBitmap2(Layout.px(cxDesign) - bmp.getWidth() / 2,
@@ -38,8 +49,9 @@ module Icons {
bmp, {:tintColor => col});
}
// Outline, charge bar and terminal. The bar tracks the real level, the
// way the comp draws it at 40%.
// 电池:外框 + 内部电量条 + 右侧触点。
// 电量条长度跟随真实电量(设计稿画的是 40% 的状态)。
// 画法是「先整块填充,再用黑色掏空内部,最后按比例填回电量条」。
function battery(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
k as Float, col as Number, level as Float) as Void {
var x0 = Layout.px(x);
@@ -62,7 +74,7 @@ module Icons {
}
}
// The standard bluetooth rune, drawn as one polyline.
// 标准蓝牙符号,用一条折线一笔画成(六段,中间的竖线会被走两次)。
function bluetooth(dc as Graphics.Dc, col as Number) as Void {
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(Layout.pen(3.2));
@@ -79,8 +91,9 @@ module Icons {
Layout.line(dc, r, qt, l, qb);
}
// Which glyph, if any, belongs above a given field id. Fields with no
// natural icon -- dates, week numbers, custom text -- read fine bare.
// 按字段编号决定上方画哪个图标。
// 日期、周数、自定义文字这类字段没有合适的图标,就不画 —— 光有数值也读得懂,
// 硬配一个反而干扰。
function forField(dc as Graphics.Dc, id as Number, xDesign as Numeric,
col as Number, level as Float) as Void {
if (id == 712 || id == 700) {
@@ -105,25 +118,25 @@ module Icons {
var cyd = Layout.ICON_CY;
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(Layout.pen(3.0));
if (id == 2 || id == 603) { // calories: flame
if (id == 2 || id == 603) { // 卡路里:火焰
dc.fillPolygon([[Layout.px(xDesign), Layout.px(cyd - r)],
[Layout.px(xDesign + r * 0.72), Layout.px(cyd + r * 0.25)],
[Layout.px(xDesign + r * 0.40), Layout.px(cyd + r * 0.85)],
[Layout.px(xDesign - r * 0.40), Layout.px(cyd + r * 0.85)],
[Layout.px(xDesign - r * 0.72), Layout.px(cyd + r * 0.25)]]);
} else if (id == 1 || id == 14 || id == 707) { // steps: two prints
} else if (id == 1 || id == 14 || id == 707) { // 步数:两个脚印
dc.fillRoundedRectangle(Layout.px(xDesign - r * 0.85), Layout.px(cyd - r * 0.75),
Layout.px(r * 0.62), Layout.px(r * 1.15),
Layout.px(r * 0.3));
dc.fillRoundedRectangle(Layout.px(xDesign + r * 0.22), Layout.px(cyd - r * 0.35),
Layout.px(r * 0.62), Layout.px(r * 1.15),
Layout.px(r * 0.3));
} else if (id == 3 || id == 9 || id == 615) { // distance: arrow
} else if (id == 3 || id == 9 || id == 615) { // 距离/海拔:箭头
dc.fillPolygon([[Layout.px(xDesign), Layout.px(cyd - r)],
[Layout.px(xDesign + r * 0.75), Layout.px(cyd + r * 0.8)],
[Layout.px(xDesign), Layout.px(cyd + r * 0.35)],
[Layout.px(xDesign - r * 0.75), Layout.px(cyd + r * 0.8)]]);
} else if (id == 618 || id == 276 || id == 623) { // heart
} else if (id == 618 || id == 276 || id == 623) { // 心率/静息/体能:心形
dc.fillCircle(Layout.px(xDesign - r * 0.38), Layout.px(cyd - r * 0.28),
Layout.px(r * 0.44));
dc.fillCircle(Layout.px(xDesign + r * 0.38), Layout.px(cyd - r * 0.28),
@@ -131,7 +144,7 @@ module Icons {
dc.fillPolygon([[Layout.px(xDesign - r * 0.78), Layout.px(cyd - r * 0.12)],
[Layout.px(xDesign + r * 0.78), Layout.px(cyd - r * 0.12)],
[Layout.px(xDesign), Layout.px(cyd + r * 0.85)]]);
} else if (id == 4 || id == 5 || id == 6) { // floors: stairs
} else if (id == 4 || id == 5 || id == 6) { // 楼层:台阶
for (var i = 0; i < 3; i++) {
dc.fillRectangle(Layout.px(xDesign - r * 0.85 + r * 0.58 * i),
Layout.px(cyd + r * 0.6 - r * 0.55 * i),