diff --git a/docs/visual-asset-quality-audit.md b/docs/visual-asset-quality-audit.md index 805526a..10ec28e 100644 --- a/docs/visual-asset-quality-audit.md +++ b/docs/visual-asset-quality-audit.md @@ -7,7 +7,7 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. - Target desktop viewport: 1280x720 - Battle map assets: 66 - Flagged battle map review rows: 43 -- Battle map source pixel density range: 112.0-224.0 px/tile +- Battle map source pixel density range: 64.0-224.0 px/tile - Story source backgrounds: 136 - Story page background references: 466 - Flagged story background review rows: 120 @@ -31,7 +31,7 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. | score | battle id | map key | grid | image | px/tile | bytes/px | concerns | | ---: | --- | --- | ---: | ---: | ---: | ---: | --- | -| 6 | `sixty-fourth-battle-weishui-camps` | `battle-map-sixty-fourth` | 128x110 | 14336x12320 | 112.0 | 0.1034 | low tile pixel density | +| 6 | `sixty-fourth-battle-weishui-camps` | `battle-map-sixty-fourth` | 128x110 | 8192x7040 | 64.0 | 0.1366 | low tile pixel density | | 6 | `sixty-sixth-battle-wuzhang-final` | `battle-map-sixty-sixth` | 132x114 | 14784x12768 | 112.0 | 0.1058 | low tile pixel density | | 6 | `sixty-fifth-battle-weishui-northbank` | `battle-map-sixty-fifth` | 130x112 | 14560x12544 | 112.0 | 0.1059 | low tile pixel density | | 6 | `sixty-third-battle-lucheng-pursuit` | `battle-map-sixty-third` | 126x108 | 14112x12096 | 112.0 | 0.1200 | low tile pixel density | @@ -103,7 +103,7 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. | `sixtieth-battle-wudu-yinping` | `battle-map-sixtieth` | 120x102 | 14400x12240 | 120.0 | 18.76 | low tile pixel density | | `sixty-fifth-battle-weishui-northbank` | `battle-map-sixty-fifth` | 130x112 | 14560x12544 | 112.0 | 18.44 | low tile pixel density | | `sixty-first-battle-hanzhong-rain-defense` | `battle-map-sixty-first` | 122x104 | 14640x12480 | 120.0 | 20.24 | low tile pixel density | -| `sixty-fourth-battle-weishui-camps` | `battle-map-sixty-fourth` | 128x110 | 14336x12320 | 112.0 | 17.41 | low tile pixel density | +| `sixty-fourth-battle-weishui-camps` | `battle-map-sixty-fourth` | 128x110 | 8192x7040 | 64.0 | 7.51 | low tile pixel density | | `sixty-second-battle-qishan-renewed-offensive` | `battle-map-sixty-second` | 124x106 | 14880x12720 | 120.0 | 22.05 | low tile pixel density | | `sixty-sixth-battle-wuzhang-final` | `battle-map-sixty-sixth` | 132x114 | 14784x12768 | 112.0 | 19.05 | low tile pixel density | | `sixty-third-battle-lucheng-pursuit` | `battle-map-sixty-third` | 126x108 | 14112x12096 | 112.0 | 19.53 | low tile pixel density | diff --git a/docs/visual-asset-sixty-fourth-map-repaint-v1-beacon.png b/docs/visual-asset-sixty-fourth-map-repaint-v1-beacon.png new file mode 100644 index 0000000..dedd274 Binary files /dev/null and b/docs/visual-asset-sixty-fourth-map-repaint-v1-beacon.png differ diff --git a/docs/visual-asset-sixty-fourth-map-repaint-v1-before-after.png b/docs/visual-asset-sixty-fourth-map-repaint-v1-before-after.png new file mode 100644 index 0000000..e0512b5 Binary files /dev/null and b/docs/visual-asset-sixty-fourth-map-repaint-v1-before-after.png differ diff --git a/docs/visual-asset-sixty-fourth-map-repaint-v1-camp.png b/docs/visual-asset-sixty-fourth-map-repaint-v1-camp.png new file mode 100644 index 0000000..22b8700 Binary files /dev/null and b/docs/visual-asset-sixty-fourth-map-repaint-v1-camp.png differ diff --git a/docs/visual-asset-sixty-fourth-map-repaint-v1-center.png b/docs/visual-asset-sixty-fourth-map-repaint-v1-center.png new file mode 100644 index 0000000..ecf5cad Binary files /dev/null and b/docs/visual-asset-sixty-fourth-map-repaint-v1-center.png differ diff --git a/docs/visual-asset-sixty-fourth-map-repaint-v1-deploy.png b/docs/visual-asset-sixty-fourth-map-repaint-v1-deploy.png new file mode 100644 index 0000000..e81199d Binary files /dev/null and b/docs/visual-asset-sixty-fourth-map-repaint-v1-deploy.png differ diff --git a/docs/visual-asset-sixty-fourth-map-repaint-v1-lower-right.png b/docs/visual-asset-sixty-fourth-map-repaint-v1-lower-right.png new file mode 100644 index 0000000..5bfe7bc Binary files /dev/null and b/docs/visual-asset-sixty-fourth-map-repaint-v1-lower-right.png differ diff --git a/docs/visual-asset-sixty-fourth-map-repaint-v1-report.md b/docs/visual-asset-sixty-fourth-map-repaint-v1-report.md new file mode 100644 index 0000000..12d5546 --- /dev/null +++ b/docs/visual-asset-sixty-fourth-map-repaint-v1-report.md @@ -0,0 +1,43 @@ +# Sixty-Fourth Battle Map Repaint v1 + +Date: 2026-07-10 + +## Target + +- Battle: `sixty-fourth-battle-weishui-camps` +- Asset: `src/assets/images/battle/sixty-fourth-battle-map.webp` +- Generator: `scripts/generate-handpaint-map-sample.py` + +## Changes + +- Added a `weishui-camps` detail profile for the 64th battle map. +- Added river current strokes, bank reeds, denser forest canopies, road ruts, camp flags, supply tents, and beacon/smoke markers. +- Rendered the source pass at 112 px/tile, then downsampled the runtime asset to 64 px/tile for browser/WebGL stability. +- Added `--webp-method` so large map export speed can be tuned without changing output quality. + +## Asset Result + +- Previous tracked-style reference: 14,336 x 12,320, 18,259,100 bytes. +- New runtime asset: 8,192 x 7,040, 7,879,728 bytes. +- The game scales battle-map art to the tactical tile layout, so the runtime image keeps desktop visual coverage while reducing single-texture pressure. + +## Visual Evidence + +- Source preview: `docs/visual-asset-sixty-fourth-map-repaint-v1-source-preview.png` +- Before/after preview: `docs/visual-asset-sixty-fourth-map-repaint-v1-before-after.png` +- Browser deployment capture: `docs/visual-asset-sixty-fourth-map-repaint-v1-deploy.png` +- Browser battle start capture: `docs/visual-asset-sixty-fourth-map-repaint-v1-start.png` +- River/center capture: `docs/visual-asset-sixty-fourth-map-repaint-v1-center.png` +- Camp/fort detail capture: `docs/visual-asset-sixty-fourth-map-repaint-v1-camp.png` +- Beacon-side capture: `docs/visual-asset-sixty-fourth-map-repaint-v1-beacon.png` +- Lower-right forest capture: `docs/visual-asset-sixty-fourth-map-repaint-v1-lower-right.png` + +## Validation + +- `python -m py_compile scripts/generate-handpaint-map-sample.py` +- `pnpm run verify:static-data` +- `pnpm run build` +- `pnpm run audit:visual-assets` +- `git diff --check` + +Browser QA reached deployment and in-battle screens on the 64th battle. A stale Phaser image-processing error remained in the browser log buffer from an earlier pre-restart recapture, but the final clean server run rendered the map, deployment UI, and in-battle detail captures successfully. diff --git a/docs/visual-asset-sixty-fourth-map-repaint-v1-source-preview.png b/docs/visual-asset-sixty-fourth-map-repaint-v1-source-preview.png new file mode 100644 index 0000000..558d182 Binary files /dev/null and b/docs/visual-asset-sixty-fourth-map-repaint-v1-source-preview.png differ diff --git a/docs/visual-asset-sixty-fourth-map-repaint-v1-start.png b/docs/visual-asset-sixty-fourth-map-repaint-v1-start.png new file mode 100644 index 0000000..daa160f Binary files /dev/null and b/docs/visual-asset-sixty-fourth-map-repaint-v1-start.png differ diff --git a/scripts/generate-handpaint-map-sample.py b/scripts/generate-handpaint-map-sample.py index ee7bb27..3786098 100644 --- a/scripts/generate-handpaint-map-sample.py +++ b/scripts/generate-handpaint-map-sample.py @@ -25,6 +25,7 @@ COLOSSAL_LARGE_MAP_TILE_SIZE = 136 GIGANTIC_LARGE_MAP_TILE_SIZE = 128 IMMENSE_LARGE_MAP_TILE_SIZE = 120 VAST_LARGE_MAP_TILE_SIZE = 112 +DESKTOP_RUNTIME_MAP_TILE_SIZE = 64 TILE = DEFAULT_TILE_SIZE RNG = random.Random(240704) @@ -643,6 +644,8 @@ MAP_CONFIGS = { "before": BATTLE_IMAGE_DIR / "sixty-fourth-battle-map.svg", "seed": 241334, "tile_size": VAST_LARGE_MAP_TILE_SIZE, + "output_tile_size": DESKTOP_RUNTIME_MAP_TILE_SIZE, + "detail_profile": "weishui-camps", }, "sixty-fifth": { "slug": "sixty-fifth-battle-map", @@ -1197,6 +1200,179 @@ def add_route_and_battlefield_details(canvas: Image.Image, terrain: list[list[st canvas.alpha_composite(details.filter(ImageFilter.GaussianBlur(0.15))) +def terrain_at(terrain: list[list[str]], x: int, y: int) -> str | None: + if 0 <= y < len(terrain) and 0 <= x < len(terrain[0]): + return terrain[y][x] + return None + + +def draw_weishui_beacon(draw: ImageDraw.ImageDraw, x: int, y: int) -> None: + cx, cy = center(x, y) + base_y = cy + int(TILE * 0.24) + tower_top = cy - int(TILE * 0.26) + left = cx - int(TILE * 0.18) + right = cx + int(TILE * 0.18) + draw.line((left, base_y, cx - 6, tower_top), fill=(67, 45, 28, 150), width=max(4, TILE // 28)) + draw.line((right, base_y, cx + 6, tower_top), fill=(67, 45, 28, 150), width=max(4, TILE // 28)) + draw.line((left - 8, cy, right + 8, cy - 5), fill=(127, 86, 48, 142), width=max(4, TILE // 32)) + draw.rectangle((cx - 18, tower_top - 6, cx + 18, tower_top + 14), fill=(88, 55, 32, 172)) + flame = [ + (cx - 16, tower_top - 6), + (cx - 4, tower_top - 40), + (cx + 6, tower_top - 14), + (cx + 18, tower_top - 46), + (cx + 15, tower_top - 3), + ] + draw.polygon([(px + 5, py + 7) for px, py in flame], fill=(64, 38, 23, 70)) + draw.polygon(flame, fill=(218, 101, 34, 176)) + draw.polygon([(cx - 6, tower_top - 5), (cx + 2, tower_top - 25), (cx + 8, tower_top - 5)], fill=(245, 181, 72, 154)) + for offset in (-24, 0, 22): + draw.arc( + (cx + offset - 20, tower_top - 76, cx + offset + 44, tower_top - 18), + 210, + 340, + fill=(72, 68, 54, 50), + width=max(2, TILE // 50), + ) + + +def draw_weishui_supply_marker(draw: ImageDraw.ImageDraw, x: int, y: int) -> None: + cx, cy = center(x, y) + tent_w = int(TILE * 0.5) + tent_h = int(TILE * 0.42) + base_y = cy + int(TILE * 0.2) + draw.polygon( + [(cx - tent_w, base_y), (cx, cy - tent_h), (cx + tent_w, base_y)], + fill=(112, 82, 43, 142), + ) + draw.polygon( + [(cx - int(tent_w * 0.72), base_y - 2), (cx, cy - int(tent_h * 0.72)), (cx + int(tent_w * 0.16), base_y - 2)], + fill=(189, 147, 73, 132), + ) + draw.line((cx - tent_w, base_y, cx + tent_w, base_y), fill=(58, 39, 25, 126), width=max(3, TILE // 34)) + for offset in (-int(TILE * 0.34), int(TILE * 0.38)): + px = cx + offset + py = cy + int(TILE * 0.33) + draw.rectangle((px - int(TILE * 0.14), py - int(TILE * 0.1), px + int(TILE * 0.14), py + int(TILE * 0.1)), fill=(139, 90, 44, 132)) + draw.line((px - int(TILE * 0.16), py - int(TILE * 0.11), px + int(TILE * 0.16), py + int(TILE * 0.1)), fill=(216, 174, 91, 82), width=2) + pole_x = cx + int(TILE * 0.48) + pole_y = cy - int(TILE * 0.06) + draw.line((pole_x, pole_y + int(TILE * 0.44), pole_x, pole_y - int(TILE * 0.38)), fill=(63, 42, 27, 150), width=max(3, TILE // 40)) + draw.polygon( + [(pole_x + 3, pole_y - int(TILE * 0.38)), (pole_x + int(TILE * 0.38), pole_y - int(TILE * 0.26)), (pole_x + 3, pole_y - int(TILE * 0.12))], + fill=(188, 59, 44, 136), + ) + + +def add_weishui_camps_details(canvas: Image.Image, terrain: list[list[str]]) -> None: + details = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(details) + sharp = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + sharp_draw = ImageDraw.Draw(sharp) + + for y, row in enumerate(terrain): + for x, kind in enumerate(row): + left, top, right, bottom = tile_box(x, y) + cx, cy = center(x, y) + + if kind == "river": + for _ in range(5): + sx = RNG.randint(left + 12, max(left + 13, right - 70)) + sy = RNG.randint(top + 16, bottom - 16) + span = RNG.randint(max(34, TILE // 3), max(42, TILE)) + draw.arc( + (sx, sy - RNG.randint(8, 18), sx + span, sy + RNG.randint(10, 24)), + 190, + 352, + fill=(*jitter(PALETTE["water_light"], 10), RNG.randint(38, 82)), + width=RNG.choice([2, 2, 3]), + ) + if terrain_at(terrain, x, y - 1) != "river": + bank_y = top + RNG.randint(6, 18) + for _ in range(4): + px = RNG.randint(left + 8, right - 8) + reed = RNG.choice([(87, 111, 60, 80), (151, 135, 78, 72), (42, 83, 51, 72)]) + draw.line((px, bank_y + RNG.randint(-2, 8), px + RNG.randint(-8, 9), bank_y - RNG.randint(8, 22)), fill=reed, width=2) + if terrain_at(terrain, x, y + 1) != "river": + bank_y = bottom - RNG.randint(7, 18) + for _ in range(4): + px = RNG.randint(left + 8, right - 8) + reed = RNG.choice([(89, 112, 61, 82), (154, 134, 78, 70), (40, 82, 50, 70)]) + draw.line((px, bank_y + RNG.randint(-6, 2), px + RNG.randint(-9, 8), bank_y - RNG.randint(10, 24)), fill=reed, width=2) + + if kind == "forest" and (x >= 92 or y >= 65 or RNG.random() < 0.22): + for _ in range(3): + bx = cx + RNG.randint(-54, 56) + by = cy + RNG.randint(-48, 48) + rx = RNG.randint(18, 42) + ry = RNG.randint(12, 30) + draw.polygon(organic_ellipse_points(bx, by, rx, ry, 14, 0.34), fill=(13, 42, 27, RNG.randint(44, 76))) + for _ in range(5): + sx = RNG.randint(left + 18, right - 18) + sy = RNG.randint(top + 24, bottom - 22) + draw.arc( + (sx - 30, sy - 18, sx + 46, sy + 22), + RNG.randint(190, 230), + RNG.randint(300, 350), + fill=(*jitter(PALETTE["forest_light"], 12), RNG.randint(46, 78)), + width=RNG.choice([2, 3]), + ) + if RNG.random() < 0.42: + draw.line( + (cx + RNG.randint(-24, 24), cy + RNG.randint(10, 42), cx + RNG.randint(-20, 28), cy + RNG.randint(38, 62)), + fill=(66, 44, 26, 92), + width=RNG.randint(4, 7), + ) + + if kind == "road" and (y >= 70 or x >= 82) and RNG.random() < 0.55: + for _ in range(2): + py = cy + RNG.randint(-28, 28) + draw.line( + (left + RNG.randint(8, 28), py, right - RNG.randint(8, 28), py + RNG.randint(-8, 8)), + fill=(103, 73, 42, RNG.randint(34, 58)), + width=RNG.choice([2, 3]), + ) + + if kind == "camp" and RNG.random() < 0.72: + cart_x = cx + RNG.randint(-34, 30) + cart_y = cy + RNG.randint(-14, 34) + draw.rectangle((cart_x - 26, cart_y - 13, cart_x + 24, cart_y + 14), fill=(111, 73, 40, 118)) + draw.line((cart_x - 32, cart_y - 17, cart_x + 28, cart_y + 18), fill=(194, 156, 91, 72), width=3) + for wx in (cart_x - 22, cart_x + 20): + draw.ellipse((wx - 8, cart_y + 10, wx + 8, cart_y + 26), outline=(47, 32, 22, 118), width=3) + for _ in range(3): + px = RNG.randint(left + 24, right - 26) + py = RNG.randint(top + 28, bottom - 24) + draw.rectangle((px - 9, py - 7, px + 11, py + 8), fill=(146, 96, 48, RNG.randint(90, 132))) + + if kind in {"camp", "fort"} and (x + y) % 9 == 0: + pole_x = cx + RNG.randint(-44, 44) + pole_y = cy + RNG.randint(-40, 30) + sharp_draw.line((pole_x, pole_y + 36, pole_x, pole_y - 38), fill=(80, 52, 31, 160), width=max(3, TILE // 38)) + sharp_draw.polygon( + [(pole_x + 4, pole_y - 37), (pole_x + 44, pole_y - 25), (pole_x + 4, pole_y - 13)], + fill=(190, 150, 49, 158), + ) + + for start, end, width in [ + ((96, 34), (118, 34), 126), + ((99, 46), (124, 46), 118), + ((63, 74), (80, 78), 112), + ((83, 68), (101, 70), 104), + ]: + paint_path_segment(draw, center(*start), center(*end), width, (204, 170, 103), 54, 15) + paint_path_segment(draw, center(*start), center(*end), max(36, width // 2), (229, 196, 133), 34, 10) + + for marker in [(59, 62), (72, 63), (101, 34), (115, 35), (70, 58)]: + draw_weishui_supply_marker(sharp_draw, *marker) + + for beacon in [(118, 72), (121, 54), (124, 33), (120, 45), (101, 61)]: + draw_weishui_beacon(sharp_draw, *beacon) + + canvas.alpha_composite(details.filter(ImageFilter.GaussianBlur(0.18))) + canvas.alpha_composite(sharp.filter(ImageFilter.GaussianBlur(0.04))) + + def add_subtle_grid_and_grade(canvas: Image.Image, terrain: list[list[str]]) -> Image.Image: width, height = canvas.size overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) @@ -1237,9 +1413,21 @@ def build_map(config: dict[str, object]) -> Image.Image: paint_roads(canvas, terrain) paint_tiles(canvas, terrain) add_route_and_battlefield_details(canvas, terrain, units) + if config.get("detail_profile") == "weishui-camps": + add_weishui_camps_details(canvas, terrain) return add_subtle_grid_and_grade(canvas, terrain) +def resize_for_runtime_if_needed(after: Image.Image, terrain: list[list[str]], config: dict[str, object]) -> Image.Image: + output_tile_size = config.get("output_tile_size") + if output_tile_size is None or int(output_tile_size) == TILE: + return after + + width = len(terrain[0]) * int(output_tile_size) + height = len(terrain) * int(output_tile_size) + return after.resize((width, height), Image.Resampling.LANCZOS) + + def make_flat_legacy_preview(terrain: list[list[str]], size: tuple[int, int]) -> Image.Image: colors = { "plain": (97, 116, 75), @@ -1301,6 +1489,7 @@ def main() -> None: parser.add_argument("--preview", type=Path) parser.add_argument("--tile-size", type=int) parser.add_argument("--webp-quality", type=int, default=90) + parser.add_argument("--webp-method", type=int, default=6) args = parser.parse_args() config = dict(MAP_CONFIGS[args.map]) @@ -1315,28 +1504,30 @@ def main() -> None: terrain = parse_battle_terrain(str(config["terrain_export"])) after = build_map(config) - before = load_before_image(before_path, terrain, after.size) - if before.size != after.size: - before = before.resize(after.size, Image.Resampling.LANCZOS) + runtime_after = resize_for_runtime_if_needed(after, terrain, config) + before = load_before_image(before_path, terrain, runtime_after.size) + if before.size != runtime_after.size: + before = before.resize(runtime_after.size, Image.Resampling.LANCZOS) out_path.parent.mkdir(parents=True, exist_ok=True) if out_path.suffix.lower() == ".webp": - after.save(out_path, "WEBP", quality=args.webp_quality, method=6) + runtime_after.save(out_path, "WEBP", quality=args.webp_quality, method=args.webp_method) else: - output = after.quantize(colors=224, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE) + output = runtime_after.quantize(colors=224, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE) output.save(out_path, optimize=True, compress_level=9) - make_comparison(before, after, comparison_path) + make_comparison(before, runtime_after, comparison_path) preview_path.parent.mkdir(parents=True, exist_ok=True) - after.resize((1400, int(after.height * 1400 / after.width)), Image.Resampling.LANCZOS).save(preview_path, quality=92) + runtime_after.resize((1400, int(runtime_after.height * 1400 / runtime_after.width)), Image.Resampling.LANCZOS).save(preview_path, quality=92) - diff_size = (400, int(after.height * 400 / after.width)) - diff = ImageChops.difference(before.resize(diff_size), after.resize(diff_size)).convert("L") + diff_size = (400, int(runtime_after.height * 400 / runtime_after.width)) + diff = ImageChops.difference(before.resize(diff_size), runtime_after.resize(diff_size)).convert("L") print( { "map": args.map, "tile_size": TILE, + "output_tile_size": config.get("output_tile_size", TILE), "out": str(out_path), - "size": after.size, + "size": runtime_after.size, "comparison": str(comparison_path), "preview": str(preview_path), "avg_preview_delta": round(sum(diff.tobytes()) / (diff_size[0] * diff_size[1]), 2), diff --git a/src/assets/images/battle/sixty-fourth-battle-map.webp b/src/assets/images/battle/sixty-fourth-battle-map.webp index d2ac3eb..5756b3d 100644 Binary files a/src/assets/images/battle/sixty-fourth-battle-map.webp and b/src/assets/images/battle/sixty-fourth-battle-map.webp differ