diff --git a/Dockerfile b/Dockerfile index 4812873d..07911984 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.11-slim AS base RUN apt update && \ - apt install -y --no-install-recommends wget unzip libicu-dev binutils git dos2unix gcc patchelf ccache make && \ + apt install -y --no-install-recommends wget unzip libicu-dev binutils git dos2unix gcc patchelf ccache make fonts-dejavu-core && \ rm -rf /var/lib/apt/lists/* WORKDIR /tools diff --git a/scripts/update_minimap.py b/scripts/update_minimap.py new file mode 100644 index 00000000..459b41fd --- /dev/null +++ b/scripts/update_minimap.py @@ -0,0 +1,87 @@ +""" +Regenerate the Midtown base map used by the game_map parser from an installed copy of Deadlock + +The minimap texture lives in game/citadel/pak01_*.vpk, which deadbot does not download, so the +map is committed to src/parser/parsers/game_map/assets/minimap_midtown_opaque.png. +Re-run this whenever the map changes. + +Requires the Source2Viewer CLI (https://github.com/ValveResourceFormat/ValveResourceFormat/releases), +passed with --decompiler or the DECOMPILER_CMD environment variable: + + python scripts/update_minimap.py --game "C:/Program Files (x86)/Steam/steamapps/common/Deadlock/game" --decompiler Source2Viewer-CLI.exe +""" + +import argparse +import os +import subprocess +import tempfile +from pathlib import Path + +from PIL import Image + +# The street map. Older revisions ship alongside it with a hash suffix, e.g. minimap_midtown_mid_psd_dd4bcbf9.vtex_c +MINIMAP_TEXTURE = 'panorama/images/minimap/base/minimap_midtown_mid_psd.vtex_c' +OUTPUT_PATH = Path('src/parser/parsers/game_map/assets/minimap_midtown_opaque.png') +# The texture is translucent everywhere, so it is drawn onto this colour to make it readable on any page background +BACKGROUND = (175, 161, 144) + + +def _srgb_to_linear(value: int) -> float: + c = value / 255 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + +def _linear_to_srgb(c: float) -> int: + c = c * 12.92 if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055 + return round(min(max(c, 0), 1) * 255) + + +SRGB_TO_LINEAR = [_srgb_to_linear(v) for v in range(256)] + + +def extract_minimap(decompiler: str, game_dir: str, texture: str) -> Image.Image: + """Export the minimap texture from pak01 as a PNG with the Source2Viewer CLI""" + vpk = os.path.join(game_dir, 'citadel', 'pak01_dir.vpk') + + with tempfile.TemporaryDirectory() as tmp: + output_dir = os.path.join(tmp, 'minimap.png') + + # With a --vpk_filepath matching a single file, --output is that file's path + subprocess.run([decompiler, '-i', vpk, '--vpk_filepath', texture, '-o', output_dir, '-d'], check=True) + + png = Path(output_dir) / Path(texture).with_suffix('.png') + with Image.open(png) as image: + return image.convert('RGBA') + + +def flatten(image: Image.Image, background: tuple[int, int, int]) -> Image.Image: + """ + Draw an RGBA image onto a solid background. Blends in linear light, like image editors do, rather than on the + raw sRGB values, which would leave the translucent map much darker + """ + background_linear = [SRGB_TO_LINEAR[c] for c in background] + rgba = image.convert('RGBA').tobytes() + pixels = bytearray() + for i in range(0, len(rgba), 4): + a = rgba[i + 3] / 255 + pixels += bytes(_linear_to_srgb(SRGB_TO_LINEAR[c] * a + bg * (1 - a)) for c, bg in zip(rgba[i : i + 3], background_linear)) + return Image.frombytes('RGB', image.size, bytes(pixels)) + + +def main(): + parser = argparse.ArgumentParser(description='Regenerate the Midtown base map asset from the game files') + parser.add_argument('--game', required=True, help='Deadlock game directory, the one containing citadel/') + parser.add_argument('--decompiler', default=os.getenv('DECOMPILER_CMD'), help='Path to the Source2Viewer CLI (default: $DECOMPILER_CMD)') + parser.add_argument('--texture', default=MINIMAP_TEXTURE, help=f'Minimap texture inside pak01 (default: {MINIMAP_TEXTURE})') + parser.add_argument('--out', default=OUTPUT_PATH, type=Path, help=f'Output path (default: {OUTPUT_PATH})') + args = parser.parse_args() + if not args.decompiler: + parser.error('pass --decompiler or set DECOMPILER_CMD to the Source2Viewer CLI') + + image = flatten(extract_minimap(args.decompiler, args.game, args.texture), BACKGROUND) + image.save(args.out, optimize=True) + print(f'Wrote {image.width}x{image.height} map to {args.out}') + + +if __name__ == '__main__': + main() diff --git a/src/parser/parser.py b/src/parser/parser.py index a2d1b081..a0afe175 100644 --- a/src/parser/parser.py +++ b/src/parser/parser.py @@ -5,6 +5,7 @@ from loguru import logger from utils import json_utils +from utils.plot_utils import save_compact_png from .parsers import ( abilities, @@ -304,14 +305,13 @@ def _parse_map(self): if self.parse_map: logger.trace('Parsing Map...') map_vpk = os.path.join(self.game_dir, 'game/citadel/maps/dl_midtown.vpk') - map_data = game_map.GameMapParser(map_vpk).run() + map_data = game_map.GameMapParser(map_vpk, self.data['scripts']['generic_data']['m_BreakableSpawnTimeDesc']).run() json_utils.write(os.path.join(self.OUTPUT_DIR, 'json/midtown-metadata.json'), map_data['midtown']['metadata']) os.makedirs(os.path.join(self.OUTPUT_DIR, 'assets'), exist_ok=True) - map_data['midtown']['plots']['golden_statues'].save(os.path.join(self.OUTPUT_DIR, 'assets/golden_statues_map.png')) - map_data['midtown']['plots']['crate'].save(os.path.join(self.OUTPUT_DIR, 'assets/crate_map.png')) - map_data['midtown']['plots']['shops'].save(os.path.join(self.OUTPUT_DIR, 'assets/shops_map.png')) + for name, plot in map_data['midtown']['plots'].items(): + save_compact_png(plot, os.path.join(self.OUTPUT_DIR, f'assets/{name}_map.png')) def _generate_resource_lookup(self, parsed_heroes, parsed_abilities, parsed_items): logger.trace('Generating resource lookup...') diff --git a/src/parser/parsers/game_map/__init__.py b/src/parser/parsers/game_map/__init__.py index 38d9c668..44931f50 100644 --- a/src/parser/parsers/game_map/__init__.py +++ b/src/parser/parsers/game_map/__init__.py @@ -1 +1,2 @@ from .__main__ import GameMapParser as GameMapParser +from .__main__ import map_file_stems as map_file_stems diff --git a/src/parser/parsers/game_map/__main__.py b/src/parser/parsers/game_map/__main__.py index bcfb54ec..e581ae7c 100644 --- a/src/parser/parsers/game_map/__main__.py +++ b/src/parser/parsers/game_map/__main__.py @@ -1,8 +1,10 @@ import json import os +from collections import Counter from os import PathLike from typing import Any, TypedDict +from loguru import logger from PIL import Image from utils.plot_utils import MapPlotter @@ -18,25 +20,80 @@ class _EntityData(TypedDict): origin: list[float] -class _BreakablesData(_EntityData): - scales: list[float] - initial_spawn_time_override: float +class _Breakable(TypedDict): + entity_class: str + label: str + title: str # Map title when plotted alone + # One colour per first spawn time, earliest first. Times come from the spawn groups in use, e.g. 3:00, 5:00, 10:00 + colors: list[str] + + +class _BreakableEntity(TypedDict): + position: tuple[float, float] + spawn_time: float | None # Seconds into the match it first spawns, or None if it has no spawn group + + +BREAKABLES: dict[str, _Breakable] = { + 'crate': { + 'entity_class': 'citadel_breakable_prop_wooden_crate', + 'label': 'Crate', + 'title': 'Crates', + 'colors': ['#1e90ff', '#22d3ee', '#a855f7'], + }, + 'heavy_crate': { + 'entity_class': 'citadel_breakable_prop_tough_crate', + 'label': 'Heavy crate', + 'title': 'Heavy crates', + 'colors': ['#22c55e', '#bef264', '#ef4444'], + }, + 'golden_statues': { + # Valve's name for these is "golden statue", players call them buff containers + 'entity_class': 'citadel_breakable_item_container', + 'label': 'Buff container', + 'title': 'Buff containers', + 'colors': ['#ffd700', '#f97316', '#ec4899'], + }, + 'healing_snack': { + # Not a breakable, but a pickup spawner that re-spawns the snack on a timer + 'entity_class': 'citadel_pickup_floating_health', + 'label': 'Healing snack', + 'title': 'Healing snacks', + 'colors': ['#f5f5f5'], + }, +} + +# Maps plotting several kinds together. Each kind also gets its own map, e.g. crate -> crate_map.png +# Output file stem -> (map title, the breakables plotted on it), e.g. all_crates -> all_crates_map.png +COMBINED_MAPS: dict[str, tuple[str, list[str]]] = { + 'all_crates': ('Crates and heavy crates', ['crate', 'heavy_crate']), + 'all_breakables': ('All breakables and healing snacks', list(BREAKABLES)), +} + +# The current Midtown minimap, regenerated with scripts/update_minimap.py +BREAKABLES_BASE_MAP = os.path.join(os.path.dirname(__file__), 'assets/minimap_midtown_opaque.png') +# Width and height of every generated map, upscaled from the base map +MAP_OUTPUT_SIZE = 2048 +# MAP_OUTPUT_SIZE is an exact multiple of the base map, so a nearest-neighbour upscale just doubles its pixels. +# Unlike a smoothing filter it adds no new colours, which keeps the maps small once saved as palette PNGs +MAP_RESAMPLE = Image.Resampling.NEAREST class GameMapParser: """Parse the Deadlock map for relevant wiki data""" - def __init__(self, game_map_path: PathLike | str): + def __init__(self, game_map_path: PathLike | str, breakable_spawn_times: list[dict[str, float]]): """ Initialize a GameMapParser for the midtown map Args: game_map_path: Path to the game map file + breakable_spawn_times: m_BreakableSpawnTimeDesc from generic_data, indexed by each breakable's spawn group """ if not os.path.exists(game_map_path): raise FileNotFoundError(f'Could not find game map at path "{game_map_path}". Run with --import_files to download the map') self.entity_helper_cmd = os.getenv('ENTITY_HELPER_CMD', 'tools/DeadlockEntityHelper') self.game_map_path = game_map_path + self.breakable_spawn_times = breakable_spawn_times def run(self) -> dict[str, GameMapData]: """ @@ -44,85 +101,82 @@ def run(self) -> dict[str, GameMapData]: Returns: A dict containing the plots and metadata for the Midtown map """ - midtown_crates_data, midtown_statues_data = self._get_breakables_data() - midtown_shop_data = self._get_shop_data() + breakables = {name: self._get_breakables(spec['entity_class']) for name, spec in BREAKABLES.items()} + # Colours are picked by spawn time order across every kind, so e.g. all 10:00 spawns use their kind's third colour + spawn_order = sorted({e['spawn_time'] for entities in breakables.values() for e in entities if e['spawn_time'] is not None}) - midtown_crate_plot = self._midtown_crate_plot(midtown_crates_data) - midtown_statues_plot = self._midtown_golden_statues_plot(midtown_statues_data) - midtown_shop_plot = self._midtown_shop_plot(midtown_shop_data) + plots = {name: self._breakables_plot({name: entities}, BREAKABLES[name]['title'], spawn_order) for name, entities in breakables.items()} + plots |= { + stem: self._breakables_plot({name: breakables[name] for name in names}, title, spawn_order) + for stem, (title, names) in COMBINED_MAPS.items() + } - midtown_metadata = {'golden_statues_count': len(midtown_statues_data), 'crate_count': len(midtown_crates_data)} + # One map per spawn time for each kind that spawns then, plus every kind together at that time, e.g. crate_5min, all_breakables_5min + for spawn_time in spawn_order: + at_time = {name: [e for e in entities if e['spawn_time'] == spawn_time] for name, entities in breakables.items()} + at_time = {name: entities for name, entities in at_time.items() if entities} + suffix = _format_minutes(spawn_time) + for name, entities in at_time.items(): + plots[f'{name}_{suffix}'] = self._breakables_plot( + {name: entities}, f'{BREAKABLES[name]["title"]} spawning at {_format_time(spawn_time)}', spawn_order + ) + plots[f'all_breakables_{suffix}'] = self._breakables_plot(at_time, f'All breakables spawning at {_format_time(spawn_time)}', spawn_order) + + plots['shops'] = self._midtown_shop_plot(self._get_shop_data()) + + metadata = {} + for name, entities in breakables.items(): + metadata[f'{name}_count'] = len(entities) + spawn_counts = Counter(_format_time(e['spawn_time']) for e in entities if e['spawn_time'] is not None) + if spawn_counts: + metadata[f'{name}_spawn_counts'] = dict(spawn_counts) return { 'midtown': { - 'plots': { - 'crate': midtown_crate_plot, - 'golden_statues': midtown_statues_plot, - 'shops': midtown_shop_plot, - }, - 'metadata': midtown_metadata, + 'plots': plots, + 'metadata': metadata, } } - def _midtown_golden_statues_plot(self, statues_data: list[_BreakablesData]) -> Image.Image: - """ - Generate a plot of the midtown golden statues - Args: - statues_data: The data for the golden statues from DeadlockEntityHelper - Returns: - The plot of the midtown golden statues - """ - glitched_statues = [[-704, -2320.0002, 704], [704, 2320.0002, 704], [3647.9998, 1440.0004, 1048.0]] - x_coords, y_coords, colors = [], [], [] - for entry in statues_data: - x_coords.append(entry['origin'][0]) - y_coords.append(entry['origin'][1]) - if entry['scales'][0] < 0.85: - colors.append('blue') - elif entry['initial_spawn_time_override'] > 0: - colors.append('green') - elif entry['origin'] in glitched_statues: - colors.append('red') - else: - colors.append('orange') - - legend = [ - ('Vent access required', 'blue'), - ('T2 Statue (spawns after 10 minutes)', 'green'), - ('Glitched Statue', 'red'), - ('Normal Statue', 'orange'), - ] - return self._create_circle_plot(x_coords, y_coords, colors, legend) - - def _midtown_crate_plot(self, crates_data: list[_BreakablesData]) -> Image.Image: + def _breakables_plot(self, breakables: dict[str, list[_BreakableEntity]], title: str, spawn_order: list[float]) -> Image.Image: """ - Generate a plot of the midtown crates + Plot one dot per breakable onto the midtown map at MAP_OUTPUT_SIZE, with a legend counting each kind. + Each kind is split by when it first spawns, in its own colour Args: - crates_data: The data for the crates from DeadlockEntityHelper + breakables: Breakable name (a key of BREAKABLES) -> its entities + title: Shown above the legend, so the image says what it is + spawn_order: Every first spawn time in use, earliest first. Picks each kind's colour for a spawn time Returns: - The plot of the midtown crates + The generated plot """ - glitched_crates = [[-7158.9175, -6115.6543, 640]] - x_coords, y_coords, colors = [], [], [] - for entry in crates_data: - x_coords.append(entry['origin'][0]) - y_coords.append(entry['origin'][1]) - if entry['origin'] in glitched_crates: - colors.append('#F52D9C') - elif entry['scales'][0] <= 0.5: - colors.append('blue') - elif entry['initial_spawn_time_override'] > 0: - colors.append('green') - else: - colors.append('#cc5500') + series = [] # [(label, color, positions), ...] + for name, entities in breakables.items(): + spec = BREAKABLES[name] + spawn_times = sorted({e['spawn_time'] for e in entities}, key=lambda t: -1 if t is None else t) + for spawn_time in spawn_times: + positions = [e['position'] for e in entities if e['spawn_time'] == spawn_time] + if spawn_time is None: + if len(spawn_times) > 1: + logger.warning(f'{spec["label"]} without a spawn time may share a colour with timed spawns') + series.append((spec['label'], spec['colors'][0], positions)) + continue + if spawn_order.index(spawn_time) >= len(spec['colors']): + logger.warning(f'No colour left for {spec["label"]} spawning at {_format_time(spawn_time)}, reusing its last colour') + color = spec['colors'][min(spawn_order.index(spawn_time), len(spec['colors']) - 1)] + series.append((f'{spec["label"]}, spawns at {_format_time(spawn_time)}', color, positions)) - legend = [ - ('Vent access required', 'blue'), - ('Glitched Crate', '#F52D9C'), - ('Mid-Boss Crate (spawns after 10 minutes)', 'green'), - ('Normal Crate', '#cc5500'), - ] - return self._create_circle_plot(x_coords, y_coords, colors, legend) + plotter = MapPlotter(BREAKABLES_BASE_MAP, MAP_OUTPUT_SIZE, MAP_RESAMPLE) + for _, color, positions in series: + plotter.place_dots(positions, color) + plotter.add_compact_legend( + [(f'{label} ({len(positions)})', color) for label, color, positions in series], + title=title, + font_size=26, + title_font_size=32, + padding=10, + swatch_size=14, + ) + return plotter.get_image() def _midtown_shop_plot(self, shop_data: list[_EntityData]) -> Image.Image: assets_dir = os.path.join(os.path.dirname(__file__), 'assets') @@ -144,29 +198,6 @@ def _midtown_shop_plot(self, shop_data: list[_EntityData]) -> Image.Image: ] return self._create_image_plot(x_coords, y_coords, image_paths, legend) - def _create_circle_plot( - self, - x_coords: list[float], - y_coords: list[float], - colors: list[str], - legend: list[tuple[str, str]], - ) -> Image.Image: - """ - Plots the given coordinates onto the midtown map - Parameters: - x_coords: The x coordinates of the points to plot - y_coords: The y coordinates of the points to plot - colors: A list of colors for the points. If `None`, markers should be provided - legend: A list of label and color pairs to display in the plot's legend - Returns: - The generated plot - """ - base_map = os.path.join(os.path.dirname(__file__), 'assets/minimap_midtown_mid_opaque.png') - plotter = MapPlotter(base_map) - plotter.place_circle_markers(x_coords, y_coords, colors, diameter=10) - plotter.add_circle_legend(legend) - return plotter.get_image() - def _create_image_plot( self, x_coords: list[float], @@ -184,8 +215,8 @@ def _create_image_plot( Returns: The generated plot """ - base_map = os.path.join(os.path.dirname(__file__), 'assets/minimap_midtown_mid_opaque.png') - plotter = MapPlotter(base_map) + base_map = BREAKABLES_BASE_MAP + plotter = MapPlotter(base_map, MAP_OUTPUT_SIZE, MAP_RESAMPLE) plotter.place_image_markers(x_coords, y_coords, image_paths, size=0.035) plotter.add_image_legend(legend) return plotter.get_image() @@ -209,31 +240,33 @@ def _get_shop_data(self) -> list[_EntityData]: # noinspection PyTypeChecker shop_data.extend( [ - {'origin': [0, -9500, 100]}, # Hidden King base shop - {'origin': [0, 9500, 100]}, # Archmother base shop + {'origin': [-1280.0, -10000.0, 100]}, # Hidden King base shop + {'origin': [1280.0, 10000.0, 100]}, # Archmother base shop ] ) return shop_data - def _get_breakables_data(self) -> tuple[list[_BreakablesData], ...]: + def _get_breakables(self, entity_class: str) -> list[_BreakableEntity]: """ - Extract breakable entities from the map - Returns: - A list of breakable entities + Extract every entity of the given class from the map, with its position and first spawn time """ - breakables_properties = [ - 'initial_spawn_time_override', - 'double', # Used to identify breakables that are spawned late - 'scales', - 'vector3', # Scale of the breakable - 'origin', - 'vector3', # Position on the map + # The helper reads this integer as 0 when asked for a double, so read it as a string. + # It's null for entities without a spawn group, e.g. healing snacks + entities = self._extract_entities('subclass_name', entity_class, 'origin', 'vector3', 'breakable_spawn_group', 'string') + return [ + { + 'position': (entity['origin'][0], entity['origin'][1]), + 'spawn_time': self._spawn_time(entity['breakable_spawn_group']), + } + for entity in entities + if entity.get('origin') ] - return ( - self._extract_entities('subclass_name', 'citadel_breakable_prop_wooden_crate', *breakables_properties), - self._extract_entities('subclass_name', 'citadel_breakable_item_container', *breakables_properties), - ) + def _spawn_time(self, spawn_group: str | None) -> float | None: + """Seconds into the match that a breakable in the given spawn group first spawns""" + if spawn_group is None: + return None + return self.breakable_spawn_times[int(spawn_group)]['m_flInitialSpawnTime'] def _extract_entities(self, entity_key, entity_value, *property_list) -> list[Any]: """ @@ -245,3 +278,27 @@ def _extract_entities(self, entity_key, entity_value, *property_list) -> list[An args = [self.entity_helper_cmd, 'extract', '--verbose', '--compact', self.game_map_path, entity_key, entity_value, *property_list] helper_output = run_process(args, 'extract-map-entities', suppress_stdout=True) return json.loads(helper_output) + + +def map_file_stems(spawn_times: list[float]) -> list[str]: + """ + Every map the parser can generate, as file stems, e.g. crate -> crate_map.png. A kind with nothing spawning + at a given time gets no map for it, so not every stem is generated on every run + Args: + spawn_times: Each breakable spawn group's first spawn time in seconds, from BreakableSpawnTimeDesc in generic_data + """ + stems = [*BREAKABLES, *COMBINED_MAPS, 'shops'] + for spawn_time in sorted(set(spawn_times)): + suffix = _format_minutes(spawn_time) + stems += [f'{name}_{suffix}' for name in BREAKABLES] + [f'all_breakables_{suffix}'] + return stems + + +def _format_time(seconds: float) -> str: + """180 -> 3:00""" + return f'{int(seconds // 60)}:{int(seconds % 60):02d}' + + +def _format_minutes(seconds: float) -> str: + """300 -> 5min, for output file names""" + return f'{int(seconds // 60)}min' if seconds % 60 == 0 else f'{int(seconds)}s' diff --git a/src/parser/parsers/game_map/assets/minimap_midtown_mid_opaque.png b/src/parser/parsers/game_map/assets/minimap_midtown_mid_opaque.png deleted file mode 100644 index 08af2be0..00000000 Binary files a/src/parser/parsers/game_map/assets/minimap_midtown_mid_opaque.png and /dev/null differ diff --git a/src/parser/parsers/game_map/assets/minimap_midtown_opaque.png b/src/parser/parsers/game_map/assets/minimap_midtown_opaque.png new file mode 100644 index 00000000..b7a91b30 Binary files /dev/null and b/src/parser/parsers/game_map/assets/minimap_midtown_opaque.png differ diff --git a/src/parser/parsers/generics.py b/src/parser/parsers/generics.py index 5a89aa2e..87e9277b 100644 --- a/src/parser/parsers/generics.py +++ b/src/parser/parsers/generics.py @@ -64,9 +64,8 @@ def _remove_prefixes(self, generic_data, possible_prefixes): if isinstance(value, dict): value = self._remove_prefixes(value, possible_prefixes) elif isinstance(value, list): - for i, elem in enumerate(value): - if isinstance(elem, dict): - value[i] = self._remove_prefixes(elem, possible_prefixes) + # Build a new list, the source data is shared with other parsers + value = [self._remove_prefixes(elem, possible_prefixes) if isinstance(elem, dict) else elem for elem in value] new[new_key] = value diff --git a/src/utils/plot_utils.py b/src/utils/plot_utils.py index 932da248..b533ed3a 100644 --- a/src/utils/plot_utils.py +++ b/src/utils/plot_utils.py @@ -1,7 +1,7 @@ import functools from os import PathLike -from PIL import Image, ImageDraw, ImageFont +from PIL import Image, ImageChops, ImageDraw, ImageFont @functools.cache @@ -10,12 +10,38 @@ def load_image(image_path: PathLike | str) -> Image.Image: return Image.open(image_path).convert('RGBA') -def create_circle_marker(color: str, diameter: int = 20) -> Image.Image: - """Creates a circular RGBA marker image of the given color.""" - img = Image.new('RGBA', (diameter, diameter), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - draw.ellipse((0, 0, diameter - 1, diameter - 1), fill=color) - return img +def save_compact_png(image: Image.Image, path: PathLike | str, max_error: int = 2) -> None: + """ + Save an opaque image as a 256 colour palette PNG when that is visually lossless, else as a full colour PNG. + A palette PNG is about a quarter of the size, but images with many shades, e.g. smooth gradients, band visibly + Args: + image: The image to save. Any transparency is dropped + path: Where to save the PNG + max_error: The most any colour channel may change, out of 255, for the palette version to be used + """ + rgb = image.convert('RGB') + palette = rgb.quantize(256, method=Image.Quantize.MAXCOVERAGE) + error = max(high for _, high in ImageChops.difference(rgb, palette.convert('RGB')).getextrema()) + (palette if error <= max_error else rgb).save(path, optimize=True) + + +# Bold fonts first. Covers the Linux docker image and local Windows runs +LEGEND_FONTS = ( + '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', + 'C:/Windows/Fonts/segoeuib.ttf', + 'C:/Windows/Fonts/arialbd.ttf', + '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', +) + + +def _load_font(paths: tuple[str, ...], size: int) -> ImageFont.ImageFont | ImageFont.FreeTypeFont: + """Returns the first font that loads from `paths`, falling back to Pillow's built-in font.""" + for path in paths: + try: + return ImageFont.truetype(path, size) + except OSError: + pass + return ImageFont.load_default(size=size) class MapPlotter: @@ -26,34 +52,31 @@ class MapPlotter: # The map coordinate extents (from the original matplotlib extent) MAP_EXTENT = 10750.0 MAP_CLIP = 10000.0 - OUTPUT_SIZE = 2000 # Output image resolution (square) - def __init__(self, base_map_path: PathLike | str): + def __init__( + self, + base_map_path: PathLike | str, + output_size: int | None = None, + resample: Image.Resampling = Image.Resampling.LANCZOS, + ): + """ + Args: + base_map_path: Path to the square base map image + output_size: Width and height to resample the base map to. `None` uses the base map as is, without resampling + resample: Filter used to resample the base map to `output_size` + """ base = load_image(base_map_path) - self.canvas = base.resize((self.OUTPUT_SIZE, self.OUTPUT_SIZE), Image.LANCZOS).copy() + self.output_size = output_size or base.width + self.canvas = base if output_size is None else base.resize((output_size, output_size), resample) - def _world_to_pixel(self, x: float, y: float) -> tuple[int, int]: - """Convert world coordinates to pixel coordinates on the output image.""" - half = self.OUTPUT_SIZE / 2 + def _world_to_pixel(self, x: float, y: float) -> tuple[float, float]: + """Convert world coordinates to sub-pixel coordinates on the output image.""" + half = self.output_size / 2 scale = half / self.MAP_EXTENT # Use full extent, not clip - px = int(half + x * scale) - py = int(half - y * scale) + px = half + x * scale + py = half - y * scale return px, py - def place_circle_markers( - self, - x_coords: list[float], - y_coords: list[float], - colors: list[str], - diameter: int = 14, - ) -> None: - """Paste circle markers at the given world coordinates.""" - for x, y, color in zip(x_coords, y_coords, colors): - marker = create_circle_marker(color, diameter) - px, py = self._world_to_pixel(x, y) - offset = diameter // 2 - self.canvas.paste(marker, (px - offset, py - offset), marker) - def place_image_markers( self, x_coords: list[float], @@ -62,51 +85,13 @@ def place_image_markers( size: float = 0.06, # Fraction of output image width ) -> None: """Paste image markers at the given world coordinates.""" - icon_size = int(self.OUTPUT_SIZE * size) + icon_size = int(self.output_size * size) for x, y, path in zip(x_coords, y_coords, image_paths): icon = load_image(path).resize((icon_size, icon_size), Image.LANCZOS) - px, py = self._world_to_pixel(x, y) + px, py = map(int, self._world_to_pixel(x, y)) offset = icon_size // 2 self.canvas.paste(icon, (px - offset, py - offset), icon) - def add_circle_legend( - self, - entries: list[tuple[str, str]], # [(label, color), ...] - font_size: int = 28, - marker_diameter: int = 20, - padding: int = 16, - ) -> None: - """Render a simple circle-icon legend in the top-left corner.""" - draw = ImageDraw.Draw(self.canvas) - try: - font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', font_size) - except OSError: - font = ImageFont.load_default() - - row_height = marker_diameter + padding - legend_height = row_height * len(entries) + padding - # Estimate max label width - max_label_width = max(draw.textlength(label, font=font) for label, _ in entries) - legend_width = int(marker_diameter + padding + max_label_width + padding * 2) - - # Draw background - draw.rectangle((padding, padding, padding + legend_width, padding + legend_height), fill=(255, 255, 255, 220)) - - for i, (label, color) in enumerate(entries): - y_top = padding * 2 + i * row_height - # Circle - draw.ellipse( - (padding * 2, y_top, padding * 2 + marker_diameter, y_top + marker_diameter), - fill=color, - ) - # Label - draw.text( - (padding * 2 + marker_diameter + padding, y_top), - label, - fill=(0, 0, 0, 255), - font=font, - ) - def add_image_legend( self, entries: list[tuple[str, PathLike | str]], # [(label, image_path), ...] @@ -139,5 +124,59 @@ def add_image_legend( font=font, ) + def place_dots( + self, + coords: list[tuple[float, float]], + color: str, + radius: float = 3.45 / 1024, # Fraction of output image width, 3.45px on a 1024px map + outline: str = 'black', + outline_width: int = 1, + ) -> None: + """Draw an outlined dot at each world coordinate, at sub-pixel precision, so overlapping dots stay distinct.""" + draw = ImageDraw.Draw(self.canvas) + # Pillow draws the outline inside the bounding box, so grow it to keep the fill at `radius` + r = self.output_size * radius + outline_width + for x, y in coords: + px, py = self._world_to_pixel(x, y) + draw.ellipse((px - r, py - r, px + r, py + r), fill=color, outline=outline, width=outline_width) + + def add_compact_legend( + self, + entries: list[tuple[str, str]], # [(label, color), ...] + title: str | None = None, + font_size: int = 10, + title_font_size: int = 16, + swatch_size: int = 7, + padding: int = 5, + ) -> None: + """Render a small legend in the top-left corner: an optional title, then one colour swatch and label per row.""" + overlay = Image.new('RGBA', self.canvas.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + font = _load_font(LEGEND_FONTS, font_size) + title_font = _load_font(LEGEND_FONTS, title_font_size) + + row_height = font_size + padding + # The title, then a gap, a divider line and another gap before the first row + title_height = title_font_size + padding * 3 if title else 0 + text_width = max(draw.textlength(label, font=font) for label, _ in entries) + box_width = int(max(swatch_size + 2 * padding + text_width, draw.textlength(title, font=title_font) + padding if title else 0) + padding) + box_height = int(title_height + row_height * len(entries) + padding) + draw.rectangle((padding, padding, padding + box_width, padding + box_height), fill=(255, 255, 255, 255)) + + # Pillow anchors text by its ascender, so nudge rows up slightly to sit level with the swatch + text_dy = font_size * -0.4 + gap = max(2, int(font_size * 0.4)) + x = padding * 2 + if title: + draw.text((x, padding * 2 + int(title_font_size * -0.15)), title, fill=(0, 0, 0, 255), font=title_font) + divider_y = padding * 2 + title_font_size + padding + draw.line((x, divider_y, padding + box_width - padding, divider_y), fill=(110, 110, 110, 255), width=1) + for i, (label, color) in enumerate(entries): + y = padding * 2 + title_height + i * row_height + draw.ellipse((x, y, x + swatch_size, y + swatch_size), fill=color, outline=(0, 0, 0, 255)) + draw.text((x + swatch_size + gap, y + text_dy), label, fill=(0, 0, 0, 255), font=font) + + self.canvas = Image.alpha_composite(self.canvas, overlay) + def get_image(self) -> Image.Image: return self.canvas diff --git a/src/wiki/pages.py b/src/wiki/pages.py index 30a68fb7..d7f9859f 100644 --- a/src/wiki/pages.py +++ b/src/wiki/pages.py @@ -49,13 +49,8 @@ 'StreetBrawlData.json': 'json/street-brawl-data.json', } -# Maps file name in "File:" namespace on the wiki to -# the file path in $OUTPUT_DIR -IMAGE_FILE_MAP = { - 'Golden_statues_map.png': 'assets/golden_statues_map.png', - 'Crate_map.png': 'assets/crate_map.png', - 'Shops_map.png': 'assets/shops_map.png', -} +# Category added to the file page of every uploaded map, from game_map.map_file_stems +IMAGE_FILE_CATEGORY = '[[Category:Maps]]' # Ignore these pages as they are not automated IGNORE_PAGES = [ diff --git a/src/wiki/upload.py b/src/wiki/upload.py index ea036ec2..b3c3c2b8 100644 --- a/src/wiki/upload.py +++ b/src/wiki/upload.py @@ -6,10 +6,11 @@ import mwclient from loguru import logger +from parser.parsers.game_map import map_file_stems from utils import game_utils, json_utils, meta_utils from . import changelog_utils -from .pages import DATA_PAGE_FILE_MAP, IGNORE_PAGES, IMAGE_FILE_MAP +from .pages import DATA_PAGE_FILE_MAP, IGNORE_PAGES, IMAGE_FILE_CATEGORY class WikiUpload: @@ -230,11 +231,20 @@ def _upload_assets(self): logger.trace(f'Assets directory not found at "{assets_dir}", skipping asset upload.') return + generic_data = json_utils.read(os.path.join(self.OUTPUT_DIR, 'json/generic-data.json'), ignore_error=True) + if generic_data is None: + logger.warning('Missing generic-data.json, which names the map files. Skipping asset upload.') + return + + spawn_times = [group['InitialSpawnTime'] for group in generic_data.get('BreakableSpawnTimeDesc', [])] logger.info('Uploading asset files...') - for wiki_filename, file_path in IMAGE_FILE_MAP.items(): - full_path = os.path.join(self.OUTPUT_DIR, file_path) + for stem in map_file_stems(spawn_times): + # Wiki file names start with a capital, e.g. File:Crate_5min_map.png + wiki_filename = f'{stem[0].upper()}{stem[1:]}_map.png' + full_path = os.path.join(self.OUTPUT_DIR, f'assets/{stem}_map.png') if not os.path.isfile(full_path): - logger.warning(f'Asset file not found: {full_path}') + # Not every map is generated on every run, e.g. without --parse_map + logger.trace(f'Asset file not found: {full_path}') continue page_title = f'File:{wiki_filename}' @@ -244,10 +254,19 @@ def _upload_assets(self): try: with open(full_path, 'rb') as f: - self.site.upload(f, filename=wiki_filename, comment=self.upload_message, ignore=True) + # The description is only used when the file page is first created + self.site.upload(f, filename=wiki_filename, description=IMAGE_FILE_CATEGORY, comment=self.upload_message, ignore=True) logger.success(f'Successfully uploaded file "{page_title}"') except Exception as e: logger.error(f'Failed to upload file "{page_title}": {e}') + continue + + # Re-uploads ignore the description, so add the category to existing file pages that lack it + page = self.site.pages[page_title] + if page.exists: + text = page.text() + if IMAGE_FILE_CATEGORY not in text: + self._update_page(page, f'{text.rstrip()}\n{IMAGE_FILE_CATEGORY}'.lstrip()) def upload_new_page(self, title, content): """