0.2.3: the install icon keeps the 0.2.x mint rook (the owner's choice), refined: a narrower tower, lighter base and more padding so it sits well among launcher icons; manifest sizes, maskable and favicons re-exported (the ICO keeps its transparent mask)

This commit is contained in:
sganggs committed 2026-10-10 02:36:12 +08:00
1 parent 9042125430
commit 9ec84f36a0
16 files changed
+95 -31

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@
- 设置新增四档文字大小,支持长译文换行;干员调配可逐干员选择中文、日语或跟随全局语音(#435、#436,PR #446,感谢 @7eLaryn)。
- 补齐司霆惊蛰、赤刃明霄陈等干员技能期间的攻击与命中音,保留各技能音效自己的音量与播放概率(PR #410,感谢 @Convey123)。
- AI 购买高阶卡时适当降低与队友开局盟约抢卡的倾向,并在混合对局中发送有限的表情回应(PR #407、#444,感谢 @IceCodeNew、@DDDarkstar;可用 `SP_BOT_EMOTES=0` 关闭表情)。
- 增加本机对局恢复入口,避免同一浏览器的闲置窗口妨碍恢复已关闭窗口的席位(#431)。浏览器支持时可添加到桌面,精简包也自带原创的系列通用要塞图标、maskable 安全区版本和 favicon(PR #413,感谢 @Cromemadnd)。
- 增加本机对局恢复入口,避免同一浏览器的闲置窗口妨碍恢复已关闭窗口的席位(#431)。浏览器支持时可添加到桌面,沿用 0.2.x 的薄荷绿城堡并精修,精简包也自带该图标、maskable 安全区版本和 favicon(PR #413,感谢 @Cromemadnd)。
- 确认页可由房主发起全体真人一致同意的开局重刷,保留房间与个人配置;取消恢复剩余确认时间,成功后重新确认(#77、PR #408,感谢 @Siornya)。这是复刻版便捷功能。
- 修复模型加载较慢时丢失入场动画,按经过时间接续,攻击或死亡后不再补播过期入场(PR #416 提示的加载问题,感谢 @Cromemadnd)。
+15 -10
View File
@@ -8,15 +8,17 @@ The original project icons below are shipped static files, not downloaded game a
## Original project application icons (#413)
`public/icons/app.svg` is the original vector master for the **卫戍协议 series**, independent of any season.
Three battlements lead into a shield-shaped wall around one square tactical cell: defending a position on a board.
The only colours are charcoal `#242424` and off-white `#f2f2f2`; the mark contains no text, gradients or fine texture.
It contains no 「盟约」 lettering, Alliance badge or season-exclusive colours / elements.
By the maintainer's revised decision of 2026-10-10, **沿用 0.2.x 的薄荷绿城堡并精修**.
`public/icons/app.svg` retains the existing mint rook's three battlements, inset tower and splayed base.
The selected refinement (B) narrows the tower, balances the battlements, reduces the base thickness and adds breathing
room. On the 512 px canvas the mark spans x=128–384 and y=88–424; the tower is 176 px wide and the base is 32 px thick.
The original colours remain mint `#4ed8af` and dark `#0c0f0e`, with no text, gradients, gloss or texture.
**Originality statement (2026-10-10):** the SVG geometry was drawn from basic rectangles and polygons for this
project. No official logo, artwork, icon or font was used, downloaded, embedded, traced or redrawn to make it.
**Provenance / originality statement (2026-10-10):** this is a proportion refinement of this project's existing
`public/icons/app.svg` from before `ba5b998a`, expressed as basic rectangles and polygons.
No official logo, artwork, icon or font was used, downloaded, embedded, traced or redrawn for this refinement.
The vector and exports are project-owned work under the repository's GPL-3.0-or-later licence, separate from the
Hypergryph / Yostar game assets below. The abstract fortification / tactical-board idea does not assert official branding.
Hypergryph / Yostar game assets below. The castle mark does not assert official branding.
Rebuild offline with Python 3 and Pillow (the same image library used by local-extract):
@@ -28,6 +30,8 @@ python3 tools/export-app-icons.py --out /tmp/stronghold-icons
The script reads only the master SVG's flat rectangle / polygon geometry, renders at 8× and downsamples; unsupported
SVG elements fail rather than silently disappearing. It needs no game art, fonts, browser or network.
ICO frames use the same native-size renders as the PNG favicons, with explicit 24-bit DIBs and opaque 1-bit masks
whose rows are padded to 4 bytes. This avoids malformed transparency masks in Pillow's RGB ICO export.
| Files in `public/icons/` | Use |
|---|---|
@@ -37,7 +41,7 @@ SVG elements fail rather than silently disappearing. It needs no game art, fonts
| `apple-touch-icon.png` | 180 px home-screen icon |
| `app.svg` | Scalable favicon and editable source |
The maskable mark is scaled to 87.5% around the canvas centre. Its outermost vertex is about 34.8% of the canvas
The maskable mark is scaled to 87.5% around the canvas centre. Its outermost vertex is about 36.1% of the canvas
width from that centre; even antialiased edge pixels stay inside the **40%-radius safe circle** specified by
[Web Application Manifest §2.3](https://www.w3.org/TR/appmanifest/#icon-masks).
The background remains opaque to every edge; launcher masks may remove the background outside the safe zone.
@@ -46,8 +50,9 @@ favicons and Apple icon; `public/manifest.json` lists all four install icons. Al
and included by the full and lite package allowlist; no changes to `data/assets.json` are needed.
`test/ui/pwa-install-023.test.js` checks decoded pixels, mask safety and references; `test/package.test.js` checks
packaging inclusion. The local handoff holds old/new light/dark, small-size and phone-layout previews with their
render script. These are offline review renders, not evidence of browser or native OS installation.
packaging inclusion. The local handoff `claude-review-023/icon-v2/` holds the original and A/B/C SVGs, light/dark
256 px and 16/32/48/96 px comparisons, and phone-layout / mask previews with their render script.
These are offline review renders, not evidence of browser or native OS installation.
## Running
+5 -2
View File
@@ -115,8 +115,11 @@ refused recovery can return to the previous identity and nickname. It does not i
Without BroadcastChannel it refuses shared recovery. Server restart / expiry clears
obsolete metadata on the next welcome. A spectator never registers a recoverable player seat.
`manifest.json` enables browser installation. The original season-independent fortress / shield mark in
`public/icons/app.svg` uses neutral greys and no official art, logos, icons, fonts or season-specific insignia.
`manifest.json` enables browser installation. The maintainer's revised decision of 2026-10-10 is to
**沿用 0.2.x 的薄荷绿城堡并精修**: `public/icons/app.svg` keeps the existing three-battlement rook, inset tower
and splayed base in mint `#4ed8af` on dark `#0c0f0e`. Refinement B narrows the tower, balances the battlements,
thins the base and increases breathing room. It has no lettering, gradients or gloss and uses no official art,
logos, icons or fonts.
`tools/export-app-icons.py` exports 192/512 px PNGs for `any` and separate opaque `maskable` icons: the latter scale
only the mark to 87.5%, keeping every foreground pixel inside the central circle of radius 40% of the canvas.
The same master supplies 16/32/48 px PNG / ICO favicons and the 180 px Apple touch icon linked from `index.html`.
+12 -4
View File
@@ -275,19 +275,27 @@ Based on @Cromemadnd's proposal, with a one-use install controller. Buttons appe
standalone/fullscreen and installed sessions hide them. Accept, dismiss, failure and duplicate clicks all consume the
same offer once. See [the prompt API](https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent/prompt)
and [installability requirements](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable).
The 2026-10-10 icon revision replaces the previous mint rook with an original, season-independent fortress / shield
and defended square. Its neutral grey palette and geometry use no official logos, artwork, icons or fonts and no
Alliance lettering or seasonal insignia. `public/icons/app.svg` is the master; `tools/export-app-icons.py` exports
The maintainer revised the icon direction on 2026-10-10: **沿用 0.2.x 的薄荷绿城堡并精修**.
The selected B refinement keeps the previous mint rook's three battlements, inset tower and splayed base,
with a narrower tower, balanced battlements, thinner base and more breathing room. It retains mint `#4ed8af`
on dark `#0c0f0e`, without text, gradients, gloss or official logos, artwork, icons or fonts.
The source is the project's `public/icons/app.svg` from before `ba5b998a`; the local `icon-v2` review includes
the original and A/B/C proposals at large and small sizes, on light/dark backgrounds and simulated home screens.
`public/icons/app.svg` is the selected B master; `tools/export-app-icons.py` exports
192/512 px `any` and separate opaque `maskable` PNGs (the mark stays inside the central 40%-radius safe circle),
16/32/48 px PNG / ICO favicons and a 180 px Apple touch icon. All ship outside optional game art, including lite
packages. See ASSETS.md for the source, reproduction command and originality statement.
The offline revision also corrects the ICO writer's transparency masks: each 16/32/48 px frame now uses its own
native-size render and 4-byte-aligned opaque mask rows. Decoding previously exposed transparent holes in the 48 px
frame; the regression checks mask layout / opacity and compares every ICO colour pixel with its PNG counterpart.
No service worker or offline cache is installed; the server is still required.
The lobby keeps Settings immediately before How to Play even when the optional install/recovery buttons appear;
the full browser run caught and corrected an initial insertion between that established pair.
The settings-entry check tests the absent board-style/background-music option labels, allowing the text-size help
to explain that the board keeps its scale.
Tests: offer lifecycle/disposal, decoded PNG dimensions / opacity / maskable safe zone, favicon references and packaging inclusion.
Tests: offer lifecycle/disposal, decoded PNG dimensions / opacity / mint palette / maskable safe zone,
three distinct battlements and an inset tower at 16/32/48 px, favicon references and packaging inclusion.
The initial feature also had real Chrome manifest and installability checks, and a synthetic
dismissed offer through the actual button. Initial incognito rejection was a test-environment limitation; the real
installability check runs in Chrome's ordinary temporary profile. Native OS installation itself is not performed.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 884 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+4 -6
View File
@@ -1,10 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<title>Stronghold Protocol — fortress and defended square</title>
<desc>Original geometric project mark. No official artwork, lettering or seasonal insignia.</desc>
<rect width="512" height="512" fill="#242424"/>
<title>Stronghold Protocol — refined mint rook</title>
<desc>Refines the existing 0.2.x project castle: three battlements, inset tower and splayed base. Original project geometry; no official artwork, lettering, gradients or texture.</desc>
<rect width="512" height="512" fill="#0c0f0e"/>
<g id="mark">
<polygon fill="#f2f2f2" points="112,112 176,112 176,152 224,152 224,112 288,112 288,152 336,152 336,112 400,112 400,304 352,352 256,416 160,352 112,304"/>
<polygon fill="#242424" points="176,216 336,216 336,280 304,312 256,344 208,312 176,280"/>
<rect x="232" y="240" width="48" height="48" fill="#f2f2f2"/>
<polygon fill="#4ed8af" points="128,88 184,88 184,128 224,128 224,88 288,88 288,128 328,128 328,88 384,88 384,184 344,224 344,352 384,392 384,424 128,424 128,392 168,352 168,224 128,184"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 659 B

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1021 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 B

After

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 B

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 569 B

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+35 -7
View File
@@ -43,7 +43,7 @@ test('installed / standalone hides stale offers; disposing removes listeners', a
assert.equal(offer().e.defaultPrevented, false);
const native = rig(true); native.offer(); assert.equal(native.pwa.available(), false); native.pwa.dispose();
});
test('manifest provides opaque 192/512 icons for both purposes with a safe maskable mark', () => {
test('manifest provides opaque mint-rook 192/512 icons for both purposes with a safe maskable mark', () => {
const m = JSON.parse(readFileSync(new URL('../../public/manifest.json', import.meta.url)));
assert.equal(m.start_url, '/'); assert.equal(m.scope, '/');
assert.deepEqual(m.icons.map((i) => `${i.purpose}:${i.sizes}`).sort(),
@@ -55,13 +55,14 @@ test('manifest provides opaque 192/512 icons for both purposes with a safe maska
const size = Number(icon.sizes.split('x')[0]);
const { w, h, rgba } = decodePng(bytes);
assert.equal(w, size); assert.equal(h, size);
let foreground = 0;
let foreground = 0, solidMint = 0;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
assert.equal(rgba[i + 3], 255, 'opaque edge-to-edge');
assert.equal(rgba[i], rgba[i + 1], 'season-independent neutral palette');
assert.equal(rgba[i], rgba[i + 2]);
if (rgba[i] === 36) continue; // #242424 background
const [r, g, b] = rgba.subarray(i, i + 3);
if (r === 12 && g === 15 && b === 14) continue; // Original #0c0f0e background
assert.ok(g >= b && b > r, 'mint palette, including antialiased edges');
if (r === 78 && g === 216 && b === 175) solidMint++; // Original #4ed8af
foreground++;
if (icon.purpose === 'maskable') {
// W3C safe zone is a circle, not merely a centred 80% square.
@@ -70,6 +71,7 @@ test('manifest provides opaque 192/512 icons for both purposes with a safe maska
}
}
assert.ok(foreground > size * size * 0.12, 'the exported mark is not empty or tiny');
assert.ok(solidMint > size * size * 0.12, 'the original flat mint identity is retained');
}
});
@@ -86,7 +88,7 @@ test('favicon and Apple links resolve to real sizes, including all legacy ICO fr
assert.ok(link.includes(`sizes="${w}x${h}"`), href);
} else if (href.endsWith('.svg')) {
assert.match(bytes.toString(), /viewBox="0 0 512 512"/);
assert.doesNotMatch(bytes.toString(), /<(?:image|text|use|foreignObject)\b|(?:href|font-family)=/);
assert.doesNotMatch(bytes.toString(), /<(?:image|text|use|foreignObject|linearGradient|radialGradient|filter)\b|(?:href|font-family)=/);
} else {
assert.equal(bytes.readUInt16LE(2), 1);
assert.equal(bytes.readUInt16LE(4), 3);
@@ -95,11 +97,37 @@ test('favicon and Apple links resolve to real sizes, including all legacy ICO fr
assert.equal(bytes[entry], size); assert.equal(bytes[entry + 1], size);
const length = bytes.readUInt32LE(entry + 8), offset = bytes.readUInt32LE(entry + 12);
assert.ok(length > 0 && offset >= 54 && offset + length <= bytes.length);
// Decode the legacy 24-bit DIB and its 1-bit transparency mask. Both row
// types need 4-byte alignment; malformed masks can punch holes in an icon.
assert.equal(bytes.readUInt32LE(offset), 40);
assert.equal(bytes.readInt32LE(offset + 4), size);
assert.equal(bytes.readInt32LE(offset + 8), size * 2);
assert.equal(bytes.readUInt16LE(offset + 14), 24);
assert.equal(bytes.readUInt32LE(offset + 16), 0);
const colorStride = Math.ceil(size * 3 / 4) * 4, maskStride = Math.ceil(size / 32) * 4;
assert.equal(length, 40 + (colorStride + maskStride) * size, `${size}px aligned ICO frame`);
const mask = bytes.subarray(offset + 40 + colorStride * size, offset + length);
assert.ok(mask.every((byte) => byte === 0), `${size}px ICO is fully opaque`);
const { rgba } = decodePng(readFileSync(new URL(`../../public/icons/favicon-${size}.png`, import.meta.url)));
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
const bmp = offset + 40 + (size - 1 - y) * colorStride + x * 3;
const png = (y * size + x) * 4;
for (let c = 0; c < 3; c++) assert.equal(bytes[bmp + 2 - c], rgba[png + c], `${size}px ICO matches PNG`);
}
}
}
}
for (const size of [16, 32, 48]) {
const { w, h } = decodePng(readFileSync(new URL(`../../public/icons/favicon-${size}.png`, import.meta.url)));
const { w, h, rgba } = decodePng(readFileSync(new URL(`../../public/icons/favicon-${size}.png`, import.meta.url)));
assert.equal(w, size); assert.equal(h, size);
// At actual favicon sizes, the three battlements must stay separated and the
// tower must remain narrower than its base. Sample visible raster features.
const row = (y) => Array.from({ length: w }, (_, x) => rgba[(Math.floor(y * h) * w + x) * 4 + 1] > 100);
const runs = (pixels) => pixels.filter((on, x) => on && !pixels[x - 1]).length;
const crown = row(0.19), tower = row(0.56), base = row(0.79);
assert.equal(runs(crown), 3, `${size}px battlement gaps stay open`);
assert.equal(runs(tower), 1, `${size}px tower stays connected`);
assert.equal(runs(base), 1, `${size}px base stays connected`);
assert.ok(tower.filter(Boolean).length < base.filter(Boolean).length, `${size}px inset tower remains legible`);
}
});
+23 -1
View File
@@ -6,7 +6,9 @@ only this source's flat rect/polygon vocabulary; unsupported SVG fails loudly.
No fonts, game art, browser, network or platform image service is involved.
"""
import argparse
from io import BytesIO
from pathlib import Path
import struct
import xml.etree.ElementTree as ET
from PIL import Image, ImageDraw
@@ -54,6 +56,26 @@ def render(size, maskable=False):
return image.resize((size, size), Image.Resampling.BOX)
def write_ico(path):
# Native-size renders preserve the same pixels as each PNG favicon. Write
# the DIBs explicitly: Pillow's RGB ICO writer does not pad 1-bit mask rows
# to 4 bytes, and can reuse the last size's mask for every smaller frame.
sizes = (16, 32, 48)
directory, frames = bytearray(), bytearray()
offset = 6 + 16 * len(sizes)
for size in sizes:
dib = BytesIO()
render(size).save(dib, format="DIB")
frame = bytearray(dib.getvalue())
# ICO DIB height includes both the colour bitmap and the AND mask.
struct.pack_into("<i", frame, 8, size * 2)
frame.extend(bytes(((size + 31) // 32) * 4 * size)) # Opaque, padded AND rows
directory.extend(struct.pack("<BBBBHHII", size, size, 0, 0, 1, 24, len(frame), offset))
frames.extend(frame)
offset += len(frame)
path.write_bytes(struct.pack("<HHH", 0, 1, len(sizes)) + directory + frames)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path, default=ROOT / "public/icons")
@@ -67,7 +89,7 @@ def main():
render(size).save(args.out / f"favicon-{size}.png")
render(180).save(args.out / "apple-touch-icon.png")
# BMP-backed frames also work in older ICO readers; PNG favicons ship separately.
render(48).save(args.out / "favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)], bitmap_format="bmp")
write_ico(args.out / "favicon.ico")
print(f"Exported 9 icon files to {args.out}")