diff --git a/docs/battle-ui-icons-v2-action-item-contact.png b/docs/battle-ui-icons-v2-action-item-contact.png new file mode 100644 index 0000000..c5bd702 Binary files /dev/null and b/docs/battle-ui-icons-v2-action-item-contact.png differ diff --git a/docs/battle-ui-icons-v2-action-item-report.md b/docs/battle-ui-icons-v2-action-item-report.md new file mode 100644 index 0000000..6177df0 --- /dev/null +++ b/docs/battle-ui-icons-v2-action-item-report.md @@ -0,0 +1,59 @@ +# Battle UI Icon V2 Action and Item Batch + +Date: 2026-07-17 + +## Scope + +This batch replaces the most visible low-detail combat and consumable icons with project-original late-Han-inspired raster art: + +| Icon | Atlas frame | Runtime use | +| --- | ---: | --- | +| attack | 4 | command menu, target selection, combat preview | +| hit | 16 | target selection and combat preview | +| critical | 17 | combat preview and result metadata | +| counter | 19 | combat preview | +| fire | 24 | fire strategy menus and previews | +| bean | 27 | battle item, sortie supply, camp supply and merchant UI | +| salve | 28 | battle item, sortie supply, camp supply and merchant UI | +| wine | 29 | battle item, sortie supply, camp supply and merchant UI | + +The existing 5x6 atlas contract is preserved. Pixel comparison against the previous tracked atlas confirms that only frames 4, 16, 17, 19, 24, 27, 28, and 29 changed. + +## Art Direction and Generation + +- Generation mode: built-in image generation, one focused generation per icon. +- Common direction: original hand-painted tactical-game UI art, compact silhouette, heavy dark outline, three to five value groups, upper-left lighting, and a restrained iron, bronze, crimson, and ochre palette. +- Runtime readability: each subject occupies about 78% of its transparent master and avoids baked frames, text, logos, watermarks, shadows, and extra objects. +- Prompt subjects: crossed Han-era blades, an arrowed bronze target, a bright critical slash, a shield with a returning arrow, a burning strategy scroll, a bean pouch, a sealed medicine jar, and a corded wine gourd. +- Alpha extraction: flat `#00ff00` chroma background removed with soft matte, despill, and one-pixel edge contraction. +- Copyright constraint: all assets are project-specific originals and do not reproduce copyrighted source art or logos. + +## Runtime Pipeline + +- `battle-ui-icons.png`: 128px HQ frames for icons displayed above 30px. +- `battle-ui-icons-micro.png`: 32px prefiltered frames with a protected one-pixel edge for icons displayed at 30px or below. +- `battleUiIconTextureForSize()` selects the correct atlas at runtime. +- Every battle usable now declares an explicit icon key; the three consumables use dedicated frames instead of the generic heal/accessory icon. +- Camp supply assignment, supply use, and merchant rows use the same dedicated consumable icon mapping. +- The atlas builder validates transparent sources, safe edges, non-empty micro frames, and emits the comparison proof automatically. + +## Outputs + +- HQ atlas: `src/assets/images/ui/battle-ui-icons.png` +- Micro atlas: `src/assets/images/ui/battle-ui-icons-micro.png` +- Transparent masters: `src/assets/images/ui/icon-sources/battle-ui-v2/*.png` +- Runtime-size comparison: `docs/battle-ui-icons-v2-action-item-contact.png` +- Rebuild command: `pnpm run generate:battle-ui-icons` + +## Verification + +- All eight masters are 1254x1254 RGBA PNGs with transparent corners and no detected green fringe. +- Comparison proof checked at 14, 20, 30, 34, 52, and 70px. +- `pnpm run verify:visual-assets` passed. +- `pnpm run verify:battle-usables` passed. +- `pnpm run verify:static-data` passed. +- `pnpm run build` passed. +- `pnpm run verify:release` passed, including new-game, reload/continue, defeat retry, victory, and camp-return flows. +- Desktop browser checks passed at 1280x720 for the command menu, battle item menu, target selection, combat preview, sortie supply assignment, camp supply rows, and merchant rows. +- Both atlases loaded successfully and no browser console errors were observed. +- Independent read-only review found no remaining blockers. diff --git a/package.json b/package.json index ba45eeb..4a72646 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "generate:audio": "node scripts/generate-bgm.mjs", "generate:camp-audio": "node scripts/generate-camp-audio.mjs", "generate:sfx": "node scripts/generate-sfx.mjs", + "generate:battle-ui-icons": "python scripts/build-battle-ui-icons.py", "export:battle-map-v2-data": "node scripts/export-battle-map-v2-data.mjs", "generate:battle-maps-v2": "node scripts/export-battle-map-v2-data.mjs && python scripts/generate-battle-maps-v2.py", "audit:equipment-icons": "node scripts/audit-equipment-icon-quality.mjs", diff --git a/scripts/build-battle-ui-icons.py b/scripts/build-battle-ui-icons.py index cd82121..d5696f4 100644 --- a/scripts/build-battle-ui-icons.py +++ b/scripts/build-battle-ui-icons.py @@ -9,39 +9,183 @@ from PIL import Image, ImageDraw, ImageEnhance, ImageFilter ROOT = Path(__file__).resolve().parents[1] OUT = ROOT / "src" / "assets" / "images" / "ui" / "battle-ui-icons.png" -SEED_ATLAS = ROOT / "docs" / "battle-ui-icons-hq-batch1-atlas.png" +MICRO_OUT = ROOT / "src" / "assets" / "images" / "ui" / "battle-ui-icons-micro.png" +PROOF_OUT = ROOT / "docs" / "battle-ui-icons-v2-action-item-contact.png" +SEED_ATLAS = ROOT / "docs" / "battle-ui-icons-hq-gapfix-v1-atlas.png" TERRAIN_SOURCE = ROOT / "src" / "assets" / "images" / "battle" / "first-battle-map.webp" +SOURCE_DIR = ROOT / "src" / "assets" / "images" / "ui" / "icon-sources" / "battle-ui-v2" BASE_FRAME = 48 FRAME = 128 +MICRO_FRAME = 32 SCALE = 4 COLS = 5 ROWS = 6 +SOURCE_FRAMES = { + 4: ("attack", "attack-v2.png"), + 16: ("hit", "hit-v2.png"), + 17: ("critical", "critical-v2.png"), + 19: ("counter", "counter-v2.png"), + 24: ("fire", "fire-v2.png"), + 27: ("bean", "bean-v2.png"), + 28: ("salve", "salve-v2.png"), + 29: ("wine", "wine-v2.png"), +} REPLACEMENT_FRAMES: dict[int, tuple[str, Callable[[Image.Image], Image.Image]]] = { - 4: ("attack", lambda atlas: compose_attack_icon(atlas)), 10: ("axe", lambda atlas: compose_axe_icon(atlas)), - 18: ("terrain", lambda atlas: compose_terrain_icon()), } def main() -> None: OUT.parent.mkdir(parents=True, exist_ok=True) + PROOF_OUT.parent.mkdir(parents=True, exist_ok=True) seed_path = SEED_ATLAS if SEED_ATLAS.exists() else OUT if not seed_path.exists(): raise FileNotFoundError(f"missing seed atlas: {seed_path}") - atlas = Image.open(seed_path).convert("RGBA") + seed_atlas = Image.open(seed_path).convert("RGBA") expected_size = (COLS * FRAME, ROWS * FRAME) - if atlas.size != expected_size: - raise ValueError(f"seed atlas must be {expected_size}, got {atlas.size}: {seed_path}") + if seed_atlas.size != expected_size: + raise ValueError(f"seed atlas must be {expected_size}, got {seed_atlas.size}: {seed_path}") + + atlas = seed_atlas.copy() + + for index, (name, filename) in SOURCE_FRAMES.items(): + tile = source_icon(SOURCE_DIR / filename) + replace_frame(atlas, index, tile) + print(f"Applied frame {index:02d} {name} from {filename}") for index, (_name, build_icon) in REPLACEMENT_FRAMES.items(): tile = build_icon(atlas) - frame_box = ((index % COLS) * FRAME, (index // COLS) * FRAME) - atlas.paste(Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0)), frame_box) - atlas.alpha_composite(tile, frame_box) + replace_frame(atlas, index, tile) + validate_source_frames(atlas) atlas.save(OUT, optimize=True) + micro_atlas = build_micro_atlas(atlas) + validate_micro_frames(micro_atlas) + micro_atlas.save(MICRO_OUT, optimize=True) + write_contact_sheet(seed_atlas, atlas, micro_atlas) print(f"Wrote {OUT}") + print(f"Wrote {MICRO_OUT}") + print(f"Wrote {PROOF_OUT}") + + +def replace_frame(atlas: Image.Image, index: int, tile: Image.Image) -> None: + frame_box = ((index % COLS) * FRAME, (index // COLS) * FRAME) + atlas.paste(Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0)), frame_box) + atlas.alpha_composite(tile.convert("RGBA"), frame_box) + + +def source_icon(path: Path) -> Image.Image: + if not path.exists(): + raise FileNotFoundError(f"missing V2 icon source: {path}") + + image = Image.open(path).convert("RGBA") + alpha = image.getchannel("A") + corner_alpha = [ + alpha.getpixel((0, 0)), + alpha.getpixel((image.width - 1, 0)), + alpha.getpixel((0, image.height - 1)), + alpha.getpixel((image.width - 1, image.height - 1)), + ] + if max(corner_alpha) > 8: + raise ValueError(f"V2 icon source must have transparent corners: {path}") + bounds = alpha.getbbox() + if bounds is None: + raise ValueError(f"V2 icon source is fully transparent: {path}") + + cropped = image.crop(bounds) + content_size = 104 + scale = min(content_size / cropped.width, content_size / cropped.height) + width = max(1, round(cropped.width * scale)) + height = max(1, round(cropped.height * scale)) + resized = cropped.resize((width, height), Image.Resampling.LANCZOS) + resized = resized.filter(ImageFilter.UnsharpMask(radius=0.7, percent=85, threshold=2)) + + tile = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0)) + tile.alpha_composite(resized, ((FRAME - width) // 2, (FRAME - height) // 2)) + return tile + + +def build_micro_atlas(atlas: Image.Image) -> Image.Image: + micro = Image.new("RGBA", (COLS * MICRO_FRAME, ROWS * MICRO_FRAME), (0, 0, 0, 0)) + for index in range(COLS * ROWS): + micro_content_size = MICRO_FRAME - 2 + frame = atlas_frame(atlas, index).resize((micro_content_size, micro_content_size), Image.Resampling.LANCZOS) + alpha = frame.getchannel("A") + frame = frame.filter(ImageFilter.UnsharpMask(radius=0.45, percent=120, threshold=1)) + frame.putalpha(alpha) + micro.alpha_composite(frame, ((index % COLS) * MICRO_FRAME + 1, (index // COLS) * MICRO_FRAME + 1)) + return micro + + +def validate_source_frames(atlas: Image.Image) -> None: + for index, (name, _filename) in SOURCE_FRAMES.items(): + bounds = atlas_frame(atlas, index).getchannel("A").getbbox() + if bounds is None: + raise ValueError(f"V2 atlas frame {index:02d} {name} is fully transparent") + width = bounds[2] - bounds[0] + height = bounds[3] - bounds[1] + if min(width, height) < 48: + raise ValueError(f"V2 atlas frame {index:02d} {name} is too narrow for runtime readability: {bounds}") + if bounds[0] < 8 or bounds[1] < 8 or bounds[2] > FRAME - 8 or bounds[3] > FRAME - 8: + raise ValueError(f"V2 atlas frame {index:02d} {name} violates the 8px safe edge: {bounds}") + + +def validate_micro_frames(atlas: Image.Image) -> None: + for index, (name, _filename) in SOURCE_FRAMES.items(): + bounds = micro_atlas_frame(atlas, index).getchannel("A").getbbox() + if bounds is None: + raise ValueError(f"micro atlas frame {index:02d} {name} is fully transparent") + if bounds[0] == 0 or bounds[1] == 0 or bounds[2] == MICRO_FRAME or bounds[3] == MICRO_FRAME: + raise ValueError(f"micro atlas frame {index:02d} {name} touches an outer edge: {bounds}") + + +def micro_atlas_frame(atlas: Image.Image, index: int) -> Image.Image: + x = (index % COLS) * MICRO_FRAME + y = (index // COLS) * MICRO_FRAME + return atlas.crop((x, y, x + MICRO_FRAME, y + MICRO_FRAME)).convert("RGBA") + + +def write_contact_sheet(seed_atlas: Image.Image, atlas: Image.Image, micro_atlas: Image.Image) -> None: + card_width = 500 + card_height = 220 + sheet = Image.new("RGB", (card_width * 4, 40 + card_height * 2), (8, 14, 20)) + draw = ImageDraw.Draw(sheet) + draw.text((14, 13), "Battle UI V2 action/item icons - before, HQ, runtime sizes", fill=(232, 220, 183)) + labels = list(SOURCE_FRAMES.items()) + runtime_sizes = (14, 20, 30, 34, 52, 70) + + for position, (index, (name, _filename)) in enumerate(labels): + column = position % 4 + row = position // 4 + left = column * card_width + top = 40 + row * card_height + draw.rounded_rectangle((left + 6, top + 6, left + card_width - 6, top + card_height - 6), radius=8, fill=(15, 25, 34), outline=(64, 83, 98), width=1) + draw.text((left + 16, top + 14), f"{index:02d} {name}", fill=(236, 221, 176)) + + before = atlas_frame(seed_atlas, index).resize((72, 72), Image.Resampling.LANCZOS) + after = atlas_frame(atlas, index).resize((72, 72), Image.Resampling.LANCZOS) + sheet.paste(before, (left + 16, top + 46), before) + sheet.paste(after, (left + 104, top + 46), after) + draw.text((left + 29, top + 124), "before", fill=(125, 145, 159)) + draw.text((left + 121, top + 124), "HQ", fill=(125, 145, 159)) + + cursor_x = left + 194 + micro_frame = micro_atlas_frame(micro_atlas, index) + hq_frame = atlas_frame(atlas, index) + for size in runtime_sizes: + source = micro_frame if size <= MICRO_FRAME else hq_frame + sample = source.resize((size, size), Image.Resampling.LANCZOS) + sample_y = top + 78 - size // 2 + sheet.paste(sample, (cursor_x, sample_y), sample) + draw.text((cursor_x, top + 124), str(size), fill=(125, 145, 159)) + cursor_x += max(size, 28) + 8 + + bounds = atlas_frame(atlas, index).getchannel("A").getbbox() + occupancy = 0 if bounds is None else round(100 * max(bounds[2] - bounds[0], bounds[3] - bounds[1]) / FRAME) + draw.text((left + 16, top + 164), f"frame {FRAME}px / micro {MICRO_FRAME}px / max span {occupancy}%", fill=(143, 164, 176)) + + sheet.save(PROOF_OUT, optimize=True) def make_icon(draw_icon: Callable[[ImageDraw.ImageDraw], None]) -> Image.Image: diff --git a/scripts/verify-visual-asset-data.mjs b/scripts/verify-visual-asset-data.mjs index 626d6ef..d280fa6 100644 --- a/scripts/verify-visual-asset-data.mjs +++ b/scripts/verify-visual-asset-data.mjs @@ -8,13 +8,26 @@ const server = await createServer({ }); const minimumReadableIconFrameSize = 96; +const expectedV2IconFrames = { + attack: 4, + hit: 16, + critical: 17, + counter: 19, + fire: 24, + bean: 27, + salve: 28, + wine: 29 +}; try { - const { battleUiIconManifest } = await server.ssrLoadModule('/src/game/data/battleUiIcons.ts'); + const { battleUiIconManifest, battleUiIconTextureForSize } = await server.ssrLoadModule('/src/game/data/battleUiIcons.ts'); + const { usableCatalog } = await server.ssrLoadModule('/src/game/data/battleUsables.ts'); const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts'); const errors = []; validateBattleUiIconAtlas(errors, battleUiIconManifest); + validateBattleUiIconTextureRouting(errors, battleUiIconManifest, battleUiIconTextureForSize); + validateBattleUsableIcons(errors, usableCatalog, battleUiIconManifest.frames); validateItemCatalog(errors, itemCatalog, equipmentSlots); validateGeneratedEquipmentIcons(errors, itemCatalog); @@ -54,6 +67,11 @@ function validateBattleUiIconAtlas(errors, manifest) { ); assertPositiveInteger(errors, manifest.columns, 'battleUiIconManifest.columns'); assertPositiveInteger(errors, manifest.rows, 'battleUiIconManifest.rows'); + assertNonEmptyString(errors, manifest.microSource, 'battleUiIconManifest.microSource'); + assertNonEmptyString(errors, manifest.microTextureKey, 'battleUiIconManifest.microTextureKey'); + assertPositiveInteger(errors, manifest.microFrameWidth, 'battleUiIconManifest.microFrameWidth'); + assertPositiveInteger(errors, manifest.microFrameHeight, 'battleUiIconManifest.microFrameHeight'); + assertPositiveInteger(errors, manifest.microMaxDisplaySize, 'battleUiIconManifest.microMaxDisplaySize'); let dimensions; try { @@ -72,6 +90,26 @@ function validateBattleUiIconAtlas(errors, manifest) { errors.push(`battle UI icon atlas must be 8-bit RGBA PNG color type 6, got bitDepth=${dimensions.bitDepth} colorType=${dimensions.colorType}`); } + let microDimensions; + try { + microDimensions = readPngDimensions(manifest.microSource); + } catch (error) { + errors.push(`battle UI micro icon atlas cannot be read at "${manifest.microSource}": ${error.message}`); + return; + } + const expectedMicroWidth = manifest.columns * manifest.microFrameWidth; + const expectedMicroHeight = manifest.rows * manifest.microFrameHeight; + if (microDimensions.width !== expectedMicroWidth || microDimensions.height !== expectedMicroHeight) { + errors.push( + `battle UI micro icon atlas is ${microDimensions.width}x${microDimensions.height}, expected ${expectedMicroWidth}x${expectedMicroHeight}` + ); + } + if (microDimensions.bitDepth !== 8 || microDimensions.colorType !== 6) { + errors.push( + `battle UI micro icon atlas must be 8-bit RGBA PNG color type 6, got bitDepth=${microDimensions.bitDepth} colorType=${microDimensions.colorType}` + ); + } + const frameLimit = manifest.columns * manifest.rows; Object.entries(manifest.frames).forEach(([key, frame]) => { assertNonEmptyString(errors, key, 'battle UI icon key'); @@ -79,6 +117,79 @@ function validateBattleUiIconAtlas(errors, manifest) { errors.push(`battle UI icon "${key}" frame ${frame} is outside atlas frame range 0..${frameLimit - 1}`); } }); + const coveredFrames = new Set(Object.values(manifest.frames)); + for (let frame = 0; frame < frameLimit; frame += 1) { + if (!coveredFrames.has(frame)) { + errors.push(`battle UI icon atlas frame ${frame} has no manifest key`); + } + } + + validateBattleUiIconSources(errors, manifest); +} + +function validateBattleUiIconSources(errors, manifest) { + const sources = manifest.v2Sources; + if (!sources || typeof sources !== 'object' || Array.isArray(sources)) { + errors.push('battleUiIconManifest.v2Sources must be an object'); + return; + } + + const entries = Object.entries(sources); + if (entries.length !== 8) { + errors.push(`battleUiIconManifest.v2Sources must contain 8 source icons, got ${entries.length}`); + } + + const sourceFrames = new Set(); + entries.forEach(([key, source]) => { + const context = `battleUiIconManifest.v2Sources.${key}`; + if (expectedV2IconFrames[key] !== source.frame) { + errors.push(`${context}.frame must remain ${expectedV2IconFrames[key]}, got ${source.frame}`); + } + if (manifest.frames[key] !== source.frame) { + errors.push(`${context}.frame ${source.frame} does not match battleUiIconManifest.frames.${key} ${manifest.frames[key]}`); + } + if (sourceFrames.has(source.frame)) { + errors.push(`${context}.frame duplicates frame ${source.frame}`); + } + sourceFrames.add(source.frame); + assertNonEmptyString(errors, source.source, `${context}.source`); + + let dimensions; + try { + dimensions = readPngDimensions(source.source); + } catch (error) { + errors.push(`${context}.source cannot be read at "${source.source}": ${error.message}`); + return; + } + if (dimensions.width !== dimensions.height || dimensions.width < 512) { + errors.push(`${context}.source must be a square master of at least 512px, got ${dimensions.width}x${dimensions.height}`); + } + if (dimensions.bitDepth !== 8 || dimensions.colorType !== 6) { + errors.push(`${context}.source must be an 8-bit RGBA PNG, got bitDepth=${dimensions.bitDepth} colorType=${dimensions.colorType}`); + } + }); +} + +function validateBattleUiIconTextureRouting(errors, manifest, textureForSize) { + if (textureForSize(14) !== manifest.microTextureKey || textureForSize(manifest.microMaxDisplaySize) !== manifest.microTextureKey) { + errors.push('battle UI icons at or below microMaxDisplaySize must use the micro atlas'); + } + if (textureForSize(manifest.microMaxDisplaySize + 1) !== manifest.textureKey || textureForSize(70) !== manifest.textureKey) { + errors.push('battle UI icons above microMaxDisplaySize must use the full atlas'); + } +} + +function validateBattleUsableIcons(errors, usableCatalog, frames) { + Object.entries(usableCatalog).forEach(([id, usable]) => { + const context = `usableCatalog.${id}.iconKey`; + assertNonEmptyString(errors, usable.iconKey, context); + if (!(usable.iconKey in frames)) { + errors.push(`${context} references unknown battle UI icon "${usable.iconKey}"`); + } + if (usable.command === 'item' && usable.iconKey !== id) { + errors.push(`${context} must use the item's dedicated icon key "${id}", got "${usable.iconKey}"`); + } + }); } function validateItemCatalog(errors, itemCatalog, equipmentSlots) { diff --git a/src/assets/images/ui/battle-ui-icons-micro.png b/src/assets/images/ui/battle-ui-icons-micro.png new file mode 100644 index 0000000..2bd3b5e Binary files /dev/null and b/src/assets/images/ui/battle-ui-icons-micro.png differ diff --git a/src/assets/images/ui/battle-ui-icons.png b/src/assets/images/ui/battle-ui-icons.png index d84c100..3d0d1d3 100644 Binary files a/src/assets/images/ui/battle-ui-icons.png and b/src/assets/images/ui/battle-ui-icons.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/attack-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/attack-v2.png new file mode 100644 index 0000000..0e44068 Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/attack-v2.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/bean-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/bean-v2.png new file mode 100644 index 0000000..7853ed3 Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/bean-v2.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/counter-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/counter-v2.png new file mode 100644 index 0000000..254d16a Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/counter-v2.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/critical-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/critical-v2.png new file mode 100644 index 0000000..92824d5 Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/critical-v2.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/fire-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/fire-v2.png new file mode 100644 index 0000000..07d4b04 Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/fire-v2.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/hit-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/hit-v2.png new file mode 100644 index 0000000..4417d80 Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/hit-v2.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/salve-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/salve-v2.png new file mode 100644 index 0000000..fe9c3d9 Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/salve-v2.png differ diff --git a/src/assets/images/ui/icon-sources/battle-ui-v2/wine-v2.png b/src/assets/images/ui/icon-sources/battle-ui-v2/wine-v2.png new file mode 100644 index 0000000..415666e Binary files /dev/null and b/src/assets/images/ui/icon-sources/battle-ui-v2/wine-v2.png differ diff --git a/src/game/data/battleUiIcons.ts b/src/game/data/battleUiIcons.ts index 43ece79..ec2ac54 100644 --- a/src/game/data/battleUiIcons.ts +++ b/src/game/data/battleUiIcons.ts @@ -1,9 +1,13 @@ import Phaser from 'phaser'; import battleUiIconsUrl from '../../assets/images/ui/battle-ui-icons.png'; +import battleUiIconsMicroUrl from '../../assets/images/ui/battle-ui-icons-micro.png'; export const battleUiIconTextureKey = 'battle-ui-icons'; +export const battleUiIconMicroTextureKey = 'battle-ui-icons-micro'; export const battleUiIconFrameSize = 128; +export const battleUiIconMicroFrameSize = 32; +export const battleUiIconMicroMaxDisplaySize = 30; export const battleUiIconFrames = { hp: 0, @@ -34,31 +38,65 @@ export const battleUiIconFrames = { fire: 24, shout: 25, confusion: 25, - failure: 26 + failure: 26, + bean: 27, + salve: 28, + wine: 29 } as const; export type BattleUiIconKey = keyof typeof battleUiIconFrames; +export const battleUiIconV2Sources = { + attack: { frame: battleUiIconFrames.attack, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/attack-v2.png' }, + hit: { frame: battleUiIconFrames.hit, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/hit-v2.png' }, + critical: { frame: battleUiIconFrames.critical, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/critical-v2.png' }, + counter: { frame: battleUiIconFrames.counter, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/counter-v2.png' }, + fire: { frame: battleUiIconFrames.fire, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/fire-v2.png' }, + bean: { frame: battleUiIconFrames.bean, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/bean-v2.png' }, + salve: { frame: battleUiIconFrames.salve, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/salve-v2.png' }, + wine: { frame: battleUiIconFrames.wine, source: 'src/assets/images/ui/icon-sources/battle-ui-v2/wine-v2.png' } +} as const satisfies Partial>; + export const battleUiIconManifest = { source: 'src/assets/images/ui/battle-ui-icons.png', textureKey: battleUiIconTextureKey, frameWidth: battleUiIconFrameSize, frameHeight: battleUiIconFrameSize, + microSource: 'src/assets/images/ui/battle-ui-icons-micro.png', + microTextureKey: battleUiIconMicroTextureKey, + microFrameWidth: battleUiIconMicroFrameSize, + microFrameHeight: battleUiIconMicroFrameSize, + microMaxDisplaySize: battleUiIconMicroMaxDisplaySize, columns: 5, rows: 6, - frames: battleUiIconFrames + frames: battleUiIconFrames, + v2Sources: battleUiIconV2Sources } as const; +export function battleUiIconTextureForSize(size: number) { + return size <= battleUiIconMicroMaxDisplaySize ? battleUiIconMicroTextureKey : battleUiIconTextureKey; +} + export function loadBattleUiIcons(scene: Phaser.Scene, onReady: () => void) { - if (scene.textures.exists(battleUiIconTextureKey)) { + const needsFullAtlas = !scene.textures.exists(battleUiIconTextureKey); + const needsMicroAtlas = !scene.textures.exists(battleUiIconMicroTextureKey); + if (!needsFullAtlas && !needsMicroAtlas) { onReady(); return; } scene.load.once('complete', onReady); - scene.load.spritesheet(battleUiIconTextureKey, battleUiIconsUrl, { - frameWidth: battleUiIconFrameSize, - frameHeight: battleUiIconFrameSize - }); + if (needsFullAtlas) { + scene.load.spritesheet(battleUiIconTextureKey, battleUiIconsUrl, { + frameWidth: battleUiIconFrameSize, + frameHeight: battleUiIconFrameSize + }); + } + if (needsMicroAtlas) { + scene.load.spritesheet(battleUiIconMicroTextureKey, battleUiIconsMicroUrl, { + frameWidth: battleUiIconMicroFrameSize, + frameHeight: battleUiIconMicroFrameSize + }); + } scene.load.start(); } diff --git a/src/game/data/battleUsables.ts b/src/game/data/battleUsables.ts index ee9a0ec..9b4d8d1 100644 --- a/src/game/data/battleUsables.ts +++ b/src/game/data/battleUsables.ts @@ -1,3 +1,5 @@ +import type { BattleUiIconKey } from './battleUiIcons'; + export type UsableCommand = 'strategy' | 'item'; export type UsableEffect = 'damage' | 'heal' | 'focus'; @@ -10,6 +12,7 @@ export type StrategyCoverageId = 'healing' | 'buff' | 'offense' | 'control'; export type BattleUsable = { id: string; + iconKey: BattleUiIconKey; command: UsableCommand; name: string; target: UsableTarget; @@ -32,6 +35,7 @@ export type BattleUsable = { export const usableCatalog: Record = { aid: { id: 'aid', + iconKey: 'heal', command: 'strategy', name: '응급', target: 'ally', @@ -43,6 +47,7 @@ export const usableCatalog: Record = { }, encourage: { id: 'encourage', + iconKey: 'focus', command: 'strategy', name: '격려', target: 'ally', @@ -58,6 +63,7 @@ export const usableCatalog: Record = { }, fireTactic: { id: 'fireTactic', + iconKey: 'fire', command: 'strategy', name: '화계', target: 'enemy', @@ -74,6 +80,7 @@ export const usableCatalog: Record = { }, roar: { id: 'roar', + iconKey: 'confusion', command: 'strategy', name: '고함', target: 'enemy', @@ -90,6 +97,7 @@ export const usableCatalog: Record = { }, benevolentCommand: { id: 'benevolentCommand', + iconKey: 'focus', command: 'strategy', name: '인덕의 호령', target: 'ally', @@ -106,6 +114,7 @@ export const usableCatalog: Record = { }, azureDragonStrike: { id: 'azureDragonStrike', + iconKey: 'strategy', command: 'strategy', name: '청룡일섬', target: 'enemy', @@ -120,6 +129,7 @@ export const usableCatalog: Record = { }, changbanRoar: { id: 'changbanRoar', + iconKey: 'confusion', command: 'strategy', name: '장판교 대갈', target: 'enemy', @@ -137,6 +147,7 @@ export const usableCatalog: Record = { }, singleRiderRescue: { id: 'singleRiderRescue', + iconKey: 'heal', command: 'strategy', name: '단기구원', target: 'ally', @@ -149,6 +160,7 @@ export const usableCatalog: Record = { }, eastWindFire: { id: 'eastWindFire', + iconKey: 'fire', command: 'strategy', name: '동남풍 화계', target: 'enemy', @@ -166,6 +178,7 @@ export const usableCatalog: Record = { }, hundredPacePierce: { id: 'hundredPacePierce', + iconKey: 'strategy', command: 'strategy', name: '백보천양', target: 'enemy', @@ -180,6 +193,7 @@ export const usableCatalog: Record = { }, westernCavalryCharge: { id: 'westernCavalryCharge', + iconKey: 'confusion', command: 'strategy', name: '서량철기', target: 'enemy', @@ -197,6 +211,7 @@ export const usableCatalog: Record = { }, qilinStratagem: { id: 'qilinStratagem', + iconKey: 'confusion', command: 'strategy', name: '기린지계', target: 'enemy', @@ -214,6 +229,7 @@ export const usableCatalog: Record = { }, bean: { id: 'bean', + iconKey: 'bean', command: 'item', name: '콩', target: 'ally', @@ -224,6 +240,7 @@ export const usableCatalog: Record = { }, salve: { id: 'salve', + iconKey: 'salve', command: 'item', name: '상처약', target: 'ally', @@ -234,6 +251,7 @@ export const usableCatalog: Record = { }, wine: { id: 'wine', + iconKey: 'wine', command: 'item', name: '술', target: 'self', diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index bc4faf5..9e40ace 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -23,7 +23,7 @@ import { } from '../data/unitAssets'; import { battleUiIconFrames, - battleUiIconTextureKey, + battleUiIconTextureForSize, loadBattleUiIcons, type BattleUiIconKey } from '../data/battleUiIcons'; @@ -8876,7 +8876,7 @@ export class BattleScene extends Phaser.Scene { if (usable.command === 'item') { if (usable.effect === 'heal') { return { - icon: 'heal' as BattleUiIconKey, + icon: usable.iconKey, powerIcon: 'heal' as BattleUiIconKey, accent: palette.gold, effectLabel: '회복', @@ -8884,7 +8884,7 @@ export class BattleScene extends Phaser.Scene { }; } return { - icon: 'accessory' as BattleUiIconKey, + icon: usable.iconKey, powerIcon: 'focus' as BattleUiIconKey, accent: palette.gold, effectLabel: '강화', @@ -8893,7 +8893,7 @@ export class BattleScene extends Phaser.Scene { } if (usable.effect === 'heal') { return { - icon: 'heal' as BattleUiIconKey, + icon: usable.iconKey, powerIcon: 'heal' as BattleUiIconKey, accent: palette.green, effectLabel: '회복', @@ -8902,7 +8902,7 @@ export class BattleScene extends Phaser.Scene { } if (usable.effect === 'focus') { return { - icon: 'focus' as BattleUiIconKey, + icon: usable.iconKey, powerIcon: 'leadership' as BattleUiIconKey, accent: palette.blue, effectLabel: '격려', @@ -8911,7 +8911,7 @@ export class BattleScene extends Phaser.Scene { } if (usable.statusEffect === 'burn' || id.includes('fire')) { return { - icon: 'fire' as BattleUiIconKey, + icon: usable.iconKey, powerIcon: 'fire' as BattleUiIconKey, accent: 0xd8732c, effectLabel: '화염', @@ -8920,7 +8920,7 @@ export class BattleScene extends Phaser.Scene { } if (usable.statusEffect === 'confusion' || id.includes('roar') || id.includes('shout')) { return { - icon: 'confusion' as BattleUiIconKey, + icon: usable.iconKey, powerIcon: 'confusion' as BattleUiIconKey, accent: 0xc96b4a, effectLabel: '위압', @@ -8928,7 +8928,7 @@ export class BattleScene extends Phaser.Scene { }; } return { - icon: 'strategy' as BattleUiIconKey, + icon: usable.iconKey, powerIcon: 'strategy' as BattleUiIconKey, accent: 0xb64a45, effectLabel: '피해', @@ -23952,7 +23952,7 @@ export class BattleScene extends Phaser.Scene { } private createBattleUiIcon(x: number, y: number, icon: BattleUiIconKey, size = 22) { - const image = this.add.image(x, y, battleUiIconTextureKey, battleUiIconFrames[icon]); + const image = this.add.image(x, y, battleUiIconTextureForSize(size), battleUiIconFrames[icon]); image.setDisplaySize(size, size); return image; } @@ -24013,7 +24013,7 @@ export class BattleScene extends Phaser.Scene { private commandIcon(command: BattleCommand, unit: UnitData): BattleUiIconKey { switch (command) { case 'attack': - return this.itemIcon(getItem(unit.equipment.weapon.itemId), 'weapon'); + return 'attack'; case 'strategy': return 'strategy'; case 'item': @@ -24031,12 +24031,12 @@ export class BattleScene extends Phaser.Scene { private actionIcon(preview: CombatPreview): BattleUiIconKey { if (preview.action === 'attack') { - return this.itemIcon(getItem(preview.attacker.equipment.weapon.itemId), 'weapon'); + return 'attack'; } if (preview.action === 'strategy') { return preview.usable ? this.usableIcon(preview.usable) : 'strategy'; } - return preview.usable ? this.itemIcon(getItem(preview.attacker.equipment.accessory.itemId), 'accessory') : 'accessory'; + return preview.usable ? this.usableIcon(preview.usable) : 'accessory'; } private itemBonusText(item: ReturnType) { diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 76b86ea..9578e52 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -1,7 +1,7 @@ import Phaser from 'phaser'; import storyFirstSortieUrl from '../../assets/images/story/05-first-sortie.png'; import { soundDirector } from '../audio/SoundDirector'; -import { battleUiIconFrames, battleUiIconTextureKey, loadBattleUiIcons, type BattleUiIconKey } from '../data/battleUiIcons'; +import { battleUiIconFrames, battleUiIconTextureForSize, loadBattleUiIcons, type BattleUiIconKey } from '../data/battleUiIcons'; import { selectCampSkin, type CampSkinSelection } from '../data/campSkins'; import { equipmentExpToNext, @@ -280,9 +280,11 @@ type SortiePortraitRosterLayout = { nextEnabled: boolean; }; +type CampSupplyId = 'bean' | 'wine' | 'salve'; + type CampSupplyDefinition = { label: string; - usableId: string; + usableId: CampSupplyId; title: string; description: string; healHp: number; @@ -290,6 +292,7 @@ type CampSupplyDefinition = { }; type MerchantItemDefinition = { + usableId: CampSupplyId; label: string; title: string; description: string; @@ -780,18 +783,21 @@ const campSupplyByLabel = new Map(campSupplies.map((supply) => [supply.label, su const merchantItems: MerchantItemDefinition[] = [ { + usableId: 'bean', label: '콩', title: '콩', description: '값싸고 가벼운 회복 도구입니다.', price: 40 }, { + usableId: 'wine', label: '탁주', title: '탁주', description: '병력 회복과 작은 공명 상승에 유용합니다.', price: 70 }, { + usableId: 'salve', label: '상처약', title: '상처약', description: '큰 부상을 빠르게 회복합니다.', @@ -15880,18 +15886,33 @@ export class CampScene extends Phaser.Scene { } private renderSortieBattleUiIcon(iconKey: BattleUiIconKey, x: number, y: number, size: number, depth: number, alpha = 1) { - if (!this.textures.exists(battleUiIconTextureKey)) { + const displaySize = this.campUiLength(size); + const textureKey = battleUiIconTextureForSize(displaySize); + if (!this.textures.exists(textureKey)) { const fallback = this.trackSortie(this.add.circle(x, y, size / 2, palette.gold, 0.18)); fallback.setDepth(depth); return; } - const icon = this.trackSortie(this.add.image(x, y, battleUiIconTextureKey, battleUiIconFrames[iconKey])); - icon.setDisplaySize(this.campUiLength(size), this.campUiLength(size)); + const icon = this.trackSortie(this.add.image(x, y, textureKey, battleUiIconFrames[iconKey])); + icon.setDisplaySize(displaySize, displaySize); icon.setAlpha(alpha); icon.setDepth(depth); } + private renderCampBattleUiIcon(iconKey: BattleUiIconKey, x: number, y: number, size: number, alpha = 1) { + const displaySize = this.campUiLength(size); + const textureKey = battleUiIconTextureForSize(displaySize); + if (!this.textures.exists(textureKey)) { + return this.track(this.add.circle(x, y, size / 2, palette.gold, 0.18)); + } + + const icon = this.track(this.add.image(x, y, textureKey, battleUiIconFrames[iconKey])); + icon.setDisplaySize(displaySize, displaySize); + icon.setAlpha(alpha); + return icon; + } + private unitClassIconKey(unit: UnitData): BattleUiIconKey { switch (unit.classKey) { case 'lord': @@ -16024,9 +16045,10 @@ export class CampScene extends Phaser.Scene { bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, assigned > 0 ? palette.green : enabled ? palette.gold : 0x53606c, assigned > 0 ? 0.78 : enabled ? 0.5 : 0.28); + this.renderSortieBattleUiIcon(supply.usableId, buttonX + 10, y + 10, 14, depth + 1, enabled ? 0.96 : 0.48); this.trackSortie( this.add.text( - buttonX + (buttonWidth - 5) / 2, + buttonX + (buttonWidth - 5) / 2 + 6, y + 5, `${supply.title} ${assigned}`, this.textStyle(9, assigned > 0 ? '#a8ffd0' : enabled ? '#c8d2dd' : '#77818c', true) @@ -21583,8 +21605,9 @@ export class CampScene extends Phaser.Scene { const bg = this.track(this.add.rectangle(x, y, width, 40, canBuy ? 0x151f2a : 0x111820, canBuy ? 0.94 : 0.76)); bg.setOrigin(0); bg.setStrokeStyle(1, canBuy ? palette.gold : 0x53606c, canBuy ? 0.58 : 0.36); - this.track(this.add.text(x + 10, y + 6, item.title, this.textStyle(13, canBuy ? '#f2e3bf' : '#7f8994', true))); - this.track(this.add.text(x + 10, y + 23, `${item.price}금`, this.textStyle(11, canBuy ? '#d8b15f' : '#7f8994', true))); + this.renderCampBattleUiIcon(item.usableId, x + 20, y + 20, 26, canBuy ? 1 : 0.5); + this.track(this.add.text(x + 40, y + 6, item.title, this.textStyle(13, canBuy ? '#f2e3bf' : '#7f8994', true))); + this.track(this.add.text(x + 40, y + 23, `${item.price}금`, this.textStyle(11, canBuy ? '#d8b15f' : '#7f8994', true))); const button = this.track(this.add.rectangle(x + width - 42, y + 20, 60, 26, canBuy ? 0x1a2630 : 0x121922, canBuy ? 0.96 : 0.72)); button.setStrokeStyle(1, canBuy ? palette.gold : 0x53606c, canBuy ? 0.72 : 0.4); @@ -21669,11 +21692,12 @@ export class CampScene extends Phaser.Scene { bg.setOrigin(0); bg.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.62 : 0.38); - this.track(this.add.text(x + 14, y + 10, `${supply.title} x${amount}`, this.textStyle(16, amount > 0 ? '#f2e3bf' : '#7f8994', true))); + this.renderCampBattleUiIcon(supply.usableId, x + 28, y + 31, 36, amount > 0 ? 1 : 0.46); + this.track(this.add.text(x + 56, y + 10, `${supply.title} x${amount}`, this.textStyle(16, amount > 0 ? '#f2e3bf' : '#7f8994', true))); this.track( - this.add.text(x + 14, y + 34, supply.description, { + this.add.text(x + 56, y + 34, supply.description, { ...this.textStyle(12, enabled ? '#c8d2dd' : '#77818c'), - wordWrap: { width: width - 128, useAdvancedWrap: true } + wordWrap: { width: width - 170, useAdvancedWrap: true } }) ); @@ -22075,8 +22099,8 @@ export class CampScene extends Phaser.Scene { private normalizedSortieItemAssignments(assignments?: CampaignSortieItemAssignments) { const selected = new Set(this.selectedSortieUnitIds); - const validSupplyIds = new Set(campSupplies.map((supply) => supply.usableId)); - const remainingBySupply = new Map(campSupplies.map((supply) => [supply.usableId, this.inventoryAmount(supply.label)])); + const validSupplyIds = new Set(campSupplies.map((supply) => supply.usableId)); + const remainingBySupply = new Map(campSupplies.map((supply) => [supply.usableId, this.inventoryAmount(supply.label)])); return Object.entries(assignments ?? {}).reduce((next, [unitId, stocks]) => { if (!selected.has(unitId)) {