diff --git a/CHANGELOG.md b/CHANGELOG.md index 140f8ace..20e0b8f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)。 diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 94635acf..0d934e25 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -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 diff --git a/docs/design/client.md b/docs/design/client.md index 40abede9..487d43e2 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -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`. diff --git a/docs/history/0.2.3.md b/docs/history/0.2.3.md index ed3510da..f695f7a1 100644 --- a/docs/history/0.2.3.md +++ b/docs/history/0.2.3.md @@ -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. diff --git a/public/icons/app-192.png b/public/icons/app-192.png index 4db28a88..2639b4e3 100644 Binary files a/public/icons/app-192.png and b/public/icons/app-192.png differ diff --git a/public/icons/app-512.png b/public/icons/app-512.png index 6facc901..30d1f10f 100644 Binary files a/public/icons/app-512.png and b/public/icons/app-512.png differ diff --git a/public/icons/app-maskable-192.png b/public/icons/app-maskable-192.png index 28a1338f..f0587b5d 100644 Binary files a/public/icons/app-maskable-192.png and b/public/icons/app-maskable-192.png differ diff --git a/public/icons/app-maskable-512.png b/public/icons/app-maskable-512.png index 9752fb4e..207b7bd8 100644 Binary files a/public/icons/app-maskable-512.png and b/public/icons/app-maskable-512.png differ diff --git a/public/icons/app.svg b/public/icons/app.svg index eebb6f02..3680a014 100644 --- a/public/icons/app.svg +++ b/public/icons/app.svg @@ -1,10 +1,8 @@ - Stronghold Protocol — fortress and defended square - Original geometric project mark. No official artwork, lettering or seasonal insignia. - + Stronghold Protocol — refined mint rook + 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. + - - - + diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png index 066f6cdc..9165f095 100644 Binary files a/public/icons/apple-touch-icon.png and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/favicon-16.png b/public/icons/favicon-16.png index caf86ffd..94fbf4f2 100644 Binary files a/public/icons/favicon-16.png and b/public/icons/favicon-16.png differ diff --git a/public/icons/favicon-32.png b/public/icons/favicon-32.png index 4d0f5fbe..7bc3815e 100644 Binary files a/public/icons/favicon-32.png and b/public/icons/favicon-32.png differ diff --git a/public/icons/favicon-48.png b/public/icons/favicon-48.png index 22cac1e4..0b84e371 100644 Binary files a/public/icons/favicon-48.png and b/public/icons/favicon-48.png differ diff --git a/public/icons/favicon.ico b/public/icons/favicon.ico index 1b3ebeee..dc41051c 100644 Binary files a/public/icons/favicon.ico and b/public/icons/favicon.ico differ diff --git a/test/ui/pwa-install-023.test.js b/test/ui/pwa-install-023.test.js index dad758cd..9d0e1c0a 100644 --- a/test/ui/pwa-install-023.test.js +++ b/test/ui/pwa-install-023.test.js @@ -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`); } }); diff --git a/tools/export-app-icons.py b/tools/export-app-icons.py index 58d08366..d2284a0d 100644 --- a/tools/export-app-icons.py +++ b/tools/export-app-icons.py @@ -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("