Source code for game.isometric_map

"""Isometric map rendering and tile management."""

import pygame
import json
import os
import random
import math
from .utils.constants import *
from .utils.asset_loader import AssetLoader

[docs] class IsometricMap: """Handles isometric map rendering and tile operations.""" def __init__(self, map_file):
[docs] self.asset_loader = AssetLoader()
[docs] self.tiles = {}
[docs] self.map_offset_x = 0
[docs] self.map_offset_y = 0
[docs] self.camera_offset_x = 0
[docs] self.camera_offset_y = 0
# This mapping should be derived from tile properties in your Tiled map. # For now, it's used for game logic (e.g., what is 'water').
[docs] self.tile_id_mapping = { 0: None, 86: 'water', 2: 'grass', 115: 'forest', 121: 'forest', 122: 'forest', 125: 'forest' }
# --- NEW: Multi-stage degradation maps --- # IMPORTANT: Update these GIDs to match your tileset in Tiled. # Stage 1: Partially degraded (e.g., sparse grass)
[docs] self.partial_degradation_gid_map = { 2: 3, # Example: 'grass' (GID 2) -> 'sparse_grass' (GID 3) 115: 111, # Example: 'forest' (GID 115) -> 'sparse_forest' (GID 116) 121: 123, 122: 4, 125: 4 }
# Stage 2: Fully degraded (e.g., dry dirt)
[docs] self.full_degradation_gid_map = { 2: 4, # Example: 'grass' (GID 2) -> 'dry_grass' (GID 4) 115: 4, # Example: 'forest' (GID 115) -> 'dry_forest' (GID 117) 121: 4, 122: 4, 125: 4 }
# GIDs that represent walkable land (grass / forest variants) self._land_gids = {2, 115, 121, 122, 125}
[docs] self.map_data_gids = self._load_map(map_file)
[docs] self.width = len(self.map_data_gids[0]) if self.map_data_gids and self.map_data_gids[0] else 0
[docs] self.height = len(self.map_data_gids) if self.map_data_gids else 0
print(f"Map loaded: {self.width}x{self.height}")
[docs] self.degradation_levels = [[0.0 for _ in range(self.width)] for _ in range(self.height)]
self._center_camera() self._generate_island_shape() # replace TMJ layout with procedural fan self._compute_shore_tiles() self._fade_surf = None # built lazily on first render call with bg_height def _load_map(self, map_file): """Load map data (GIDs) from a .tmj file and load tileset images.""" try: with open(map_file, 'r') as f: tmj_data = json.load(f) except (FileNotFoundError, json.JSONDecodeError) as e: print(f"Warning: Could not load or parse '{map_file}'. Error: {e}") # Fallback to a default map return self._create_default_map_gids() # --- Load Tileset Images --- self.tiles = {} # Reset tiles dictionary tilesets_data = tmj_data.get('tilesets', []) if not tilesets_data: print("Warning: No tilesets found in TMJ file. Tiles will not render.") for tileset_info in tilesets_data: first_gid = tileset_info.get('firstgid', 1) tileset_source = tileset_info.get('source') if not tileset_source: print("Warning: Tileset entry found without a 'source' file. Skipping.") continue if not tileset_source.endswith('.tsj'): print(f"Warning: Skipping unsupported tileset format '{tileset_source}'. This loader only supports .tsj (JSON) files, not .tsx (XML).") continue map_dir = os.path.dirname(map_file) tsj_path = os.path.join(map_dir, tileset_source) print(f"Attempting to load tileset data from: {tsj_path}") try: with open(tsj_path, 'r') as f: tsj_data = json.load(f) except (FileNotFoundError, json.JSONDecodeError) as e: print(f"ERROR: Could not load or parse tileset file '{tsj_path}'. Error: {e}. Skipping this tileset.") continue tileset_image_relative_path = tsj_data.get('image') tsj_tile_width = tsj_data.get('tilewidth', 0) tsj_tile_height = tsj_data.get('tileheight', 0) margin = tsj_data.get('margin', 0) spacing = tsj_data.get('spacing', 0) img_width = tsj_data.get('imagewidth', 0) img_height = tsj_data.get('imageheight', 0) if not all([tileset_image_relative_path, tsj_tile_width, tsj_tile_height, img_width, img_height]): print("ERROR: Missing required information in tileset file. Skipping this tileset.") continue tileset_image_path = os.path.normpath(os.path.join(map_dir, tileset_image_relative_path)) print(f"Attempting to load tileset image from: {tileset_image_path}") tileset_image = self.asset_loader.load_map_image(tileset_image_path) if not tileset_image: print(f"ERROR: Could not load tileset image from {tileset_image_path}. Skipping this tileset.") continue tiles_across = (img_width - 2 * margin + spacing) // (tsj_tile_width + spacing) tiles_down = (img_height - 2 * margin + spacing) // (tsj_tile_height + spacing) gid_counter = first_gid for row in range(tiles_down): for col in range(tiles_across): x = margin + col * (tsj_tile_width + spacing) y = margin + row * (tsj_tile_height + spacing) if x + tsj_tile_width <= img_width and y + tsj_tile_height <= img_height: tile_surface = tileset_image.subsurface(pygame.Rect(x, y, tsj_tile_width, tsj_tile_height)) self.tiles[gid_counter] = tile_surface gid_counter += 1 print(f"Loaded {len(self.tiles)} total tile surfaces from all tilesets.") # --- Process Layer Data (Store GIDs) --- if tmj_data.get('infinite', False): return self._process_infinite_layer_data(tmj_data) else: width = tmj_data.get('width', 0) height = tmj_data.get('height', 0) if not width or not height: print("Warning: Invalid map dimensions in finite TMJ file. Creating default map.") return self._create_default_map_gids() return self._process_finite_layer_data(tmj_data, width, height) def _process_finite_layer_data(self, tmj_data, width, height): """Extract tile GIDs from layers of a finite map.""" map_data_gids = [[0 for _ in range(width)] for _ in range(height)] for layer_idx, layer in enumerate(tmj_data.get('layers', [])): if layer.get('type') != 'tilelayer' or 'data' not in layer: continue layer_name = layer.get('name', f'layer_{layer_idx}') print(f"Processing finite layer (storing GIDs): {layer_name}") tile_data = layer.get('data', []) for i, tile_gid in enumerate(tile_data): if tile_gid != 0: x = i % width y = i // width if 0 <= y < height and 0 <= x < width: map_data_gids[y][x] = tile_gid return map_data_gids def _process_infinite_layer_data(self, tmj_data): """Extract tile GIDs from the chunks of an infinite map.""" min_x, min_y = float('inf'), float('inf') max_x, max_y = float('-inf'), float('-inf') layers = [layer for layer in tmj_data.get('layers', []) if layer.get('type') == 'tilelayer'] if not any(layer.get('chunks') for layer in layers): print("Warning: Infinite map has no chunks. Creating default map.") return self._create_default_map_gids() for layer in layers: for chunk in layer.get('chunks', []): min_x = min(min_x, chunk['x']) min_y = min(min_y, chunk['y']) max_x = max(max_x, chunk['x'] + chunk['width']) max_y = max(max_y, chunk['y'] + chunk['height']) self.map_offset_x = min_x self.map_offset_y = min_y map_width = max_x - min_x map_height = max_y - min_y print(f"Infinite map bounds calculated: w={map_width}, h={map_height}, offset=({min_x},{min_y})") map_data_gids = [[0 for _ in range(map_width)] for _ in range(map_height)] for layer in layers: for chunk in layer.get('chunks', []): for i, gid in enumerate(chunk.get('data', [])): if gid != 0: tile_x = chunk['x'] + (i % chunk['width']) tile_y = chunk['y'] + (i // chunk['width']) array_x = tile_x - self.map_offset_x array_y = tile_y - self.map_offset_y if 0 <= array_y < map_height and 0 <= array_x < map_width: map_data_gids[array_y][array_x] = gid return map_data_gids def _create_default_map_gids(self): """Creates a default map and returns its GID data.""" default_map_types = self._create_default_map() default_map_gids = [] for row in default_map_types: gid_row = [] for tile_type in row: gid = next((key for key, val in self.tile_id_mapping.items() if val == tile_type), 0) gid_row.append(gid) default_map_gids.append(gid_row) return default_map_gids def _process_layer_data(self, tmj_data, width, height): """This method is deprecated, use _process_finite_layer_data or _process_infinite_layer_data.""" print("Warning: _process_layer_data is deprecated.") return self._process_finite_layer_data(tmj_data, width, height) def _create_default_map(self): """Create a default island map (returns tile types).""" # Use the constants from your file, or fallback values map_width = getattr(self, 'MAP_WIDTH', 30) map_height = getattr(self, 'MAP_HEIGHT', 20) # Try to get from constants module try: map_width = MAP_WIDTH map_height = MAP_HEIGHT except NameError: map_width = 30 map_height = 20 print(f"Creating default map: {map_width}x{map_height}") map_data = [] for y in range(map_height): row = [] for x in range(map_width): # Create island shape center_x, center_y = map_width // 2, map_height // 2 distance = ((x - center_x) ** 2 + (y - center_y) ** 2) ** 0.5 if distance > 8: row.append('water') elif distance > 6: row.append('grass') else: row.append('forest') map_data.append(row) return map_data # ── Procedural island ──────────────────────────────────────────────────── def _generate_island_shape(self): """Replace map_data_gids with a procedural fan-shaped island. Near (bottom screen, high wx+wy): narrow beach. Far (top screen, low wx+wy): wide, fills screen edge-to-edge then disappears into the depth fade. """ WATER_GID = 85 LAND_GID = 2 NEAR_LIMIT = 18 # d where beach starts (screen_y ≈ 818) FAR_LIMIT = -32 # d where back edge is (screen_y ≈ 18, hidden by fade) DEPTH_SPAN = NEAR_LIMIT - FAR_LIMIT # 50 NEAR_HALF = 4 # half-width in spread tiles at beach FAR_HALF = 22 # half-width at back (≈700 px, fills beyond screen) rng = random.Random() phase1 = rng.uniform(0, 2 * math.pi) phase2 = rng.uniform(0, 2 * math.pi) for ay in range(self.height): for ax in range(self.width): wx = ax + self.map_offset_x wy = ay + self.map_offset_y d = wx + wy # isometric depth (high = near/front) s = wx - wy # isometric spread (left/right on screen) if d > NEAR_LIMIT or d < FAR_LIMIT: self.map_data_gids[ay][ax] = WATER_GID continue t = (NEAR_LIMIT - d) / DEPTH_SPAN # 0 = near, 1 = far half_width = NEAR_HALF + t * (FAR_HALF - NEAR_HALF) # Organic coastline noise — amplitude scales with depth so the # beach stays coherent but the back edge gets a rugged silhouette noise = (t * 3.0 * math.sin(wx * 0.45 + wy * 0.30 + phase1) + t * 1.5 * math.sin(wx * 0.85 - wy * 0.55 + phase2)) half_width = max(2, half_width + noise) self.map_data_gids[ay][ax] = (LAND_GID if abs(s) < half_width else WATER_GID)
[docs] def randomize_land_tiles(self, seed=None): """Re-distribute grass and forest across land tiles; island shape unchanged.""" rng = random.Random(seed) forest_ratio = 0.30 for ay in range(self.height): for ax in range(self.width): if self.map_data_gids[ay][ax] in self._land_gids: self.map_data_gids[ay][ax] = 115 if rng.random() < forest_ratio else 2
# ── Shore tile pre-computation ──────────────────────────────────────────── def _compute_shore_tiles(self): """Identify water/edge tiles that border at least one land tile.""" self.shore_tiles = set() for ay in range(self.height): for ax in range(self.width): gid = self.map_data_gids[ay][ax] if gid == 0 or gid in self._land_gids: continue for dy in (-1, 0, 1): for dx in (-1, 0, 1): if dx == 0 and dy == 0: continue ny, nx = ay + dy, ax + dx if 0 <= ny < self.height and 0 <= nx < self.width: if self.map_data_gids[ny][nx] in self._land_gids: self.shore_tiles.add((ax, ay)) break else: continue break # ── Distance fade ───────────────────────────────────────────────────────── def _build_distance_fade(self, bg_height, fade_height=240): """Pre-compute the depth-haze gradient surface (sky colour → transparent).""" surf = pygame.Surface((SCREEN_WIDTH, fade_height), pygame.SRCALPHA) r, g, b = 165, 205, 220 # pale Pacific sky for i in range(fade_height): alpha = int(230 * ((1.0 - i / fade_height) ** 1.6)) pygame.draw.line(surf, (r, g, b, alpha), (0, i), (SCREEN_WIDTH, i)) self._fade_surf = surf self._fade_bg_height = bg_height def _center_camera(self): """Calculates the camera offset to center the map on the screen.""" if self.width == 0 or self.height == 0: self.camera_offset_x = SCREEN_WIDTH // 2 self.camera_offset_y = 100 return # Find the center of the map in world coordinates center_world_x = self.map_offset_x + self.width / 2.0 center_world_y = self.map_offset_y + self.height / 2.0 # Calculate where this world center would appear on screen without any camera offset unoffset_screen_x = (center_world_x - center_world_y) * TILE_OFFSET_X unoffset_screen_y = (center_world_x + center_world_y) * TILE_OFFSET_Y # Calculate the offset needed to move this point to the screen's center, # with a small vertical adjustment to account for the top HUD. vertical_screen_offset = 80 self.camera_offset_x = (SCREEN_WIDTH // 2) - unoffset_screen_x self.camera_offset_y = (SCREEN_HEIGHT // 2) - unoffset_screen_y + vertical_screen_offset
[docs] def world_to_array(self, world_x, world_y): """Converts world coordinates to map array indices.""" return int(world_x - self.map_offset_x), int(world_y - self.map_offset_y)
[docs] def array_to_world(self, array_x, array_y): """Converts map array indices to world coordinates.""" return array_x + self.map_offset_x, array_y + self.map_offset_y
def _create_tile_surface(self, color): """Create a basic isometric tile surface (used for fallback/debug).""" # This method is less relevant now that we load from tilesets, # but kept for potential fallback or other uses. if not color or len(color) < 3: print(f"Warning: Invalid color {color}, using default") color = (100, 100, 100) # Default gray try: tile_width = TILE_WIDTH tile_height = TILE_HEIGHT except NameError: tile_width = 64 tile_height = 32 print("Warning: TILE_WIDTH/TILE_HEIGHT not defined, using defaults for _create_tile_surface") surface = pygame.Surface((tile_width, tile_height), pygame.SRCALPHA) points = [ (tile_width // 2, 0), (tile_width, tile_height // 2), (tile_width // 2, tile_height), (0, tile_height // 2) ] try: pygame.draw.polygon(surface, color, points) pygame.draw.polygon(surface, (0, 0, 0), points, 2) except Exception as e: print(f"Error drawing tile surface in _create_tile_surface: {e}") return None return surface
[docs] def world_to_screen(self, world_x, world_y): """Convert world coordinates (tile grid) to screen coordinates (pixels).""" screen_x = (world_x - world_y) * TILE_OFFSET_X + self.camera_offset_x screen_y = (world_x + world_y) * TILE_OFFSET_Y + self.camera_offset_y return screen_x, screen_y
[docs] def screen_to_world(self, screen_x, screen_y): """Convert screen coordinates (pixels) to world coordinates (tile grid).""" # Reverse the camera offset screen_x -= self.camera_offset_x screen_y -= self.camera_offset_y # Avoid division by zero if offsets are invalid if TILE_OFFSET_X == 0 or TILE_OFFSET_Y == 0: return -1, -1 world_x = (screen_x / TILE_OFFSET_X + screen_y / TILE_OFFSET_Y) / 2 world_y = (screen_y / TILE_OFFSET_Y - screen_x / TILE_OFFSET_X) / 2 # Return integer grid coordinates return int(round(world_x)), int(round(world_y))
[docs] def get_tile_type(self, x, y): """Get the tile type (string) at given WORLD coordinates.""" ax, ay = self.world_to_array(x, y) if not (0 <= ax < self.width and 0 <= ay < self.height): return 'water' gid = self.map_data_gids[ay][ax] base_type = self.tile_id_mapping.get(gid, 'unknown') if base_type in ['grass', 'forest']: degradation = self.degradation_levels[ay][ax] if degradation > 0.5: if base_type == 'grass': return 'degraded_grass' elif base_type == 'forest': return 'degraded_forest' return base_type
[docs] def add_degradation(self, x, y, amount): """Add degradation to a tile at WORLD coordinates.""" ax, ay = self.world_to_array(x, y) if 0 <= ax < self.width and 0 <= ay < self.height: self.degradation_levels[ay][ax] = min(1.0, self.degradation_levels[ay][ax] + amount)
[docs] def get_degradation(self, x, y): """Get degradation level at WORLD coordinates.""" ax, ay = self.world_to_array(x, y) if 0 <= ax < self.width and 0 <= ay < self.height: return self.degradation_levels[ay][ax] return 0.0
[docs] def is_valid_position(self, x, y): """Check if WORLD position is valid (within bounds and not water).""" ax, ay = self.world_to_array(x, y) if not (0 <= ax < self.width and 0 <= ay < self.height): return False gid = self.map_data_gids[ay][ax] return self.tile_id_mapping.get(gid) != 'water'
[docs] def render(self, screen, bg_height=0): """Render the isometric map using loaded tile images.""" if not self.map_data_gids or not self.tiles: return # Build depth-fade surface once if bg_height > 0 and self._fade_surf is None: self._build_distance_fade(bg_height) try: tile_offset_y = TILE_OFFSET_Y except NameError: tile_offset_y = 16 PARTIAL_DEGRADATION_THRESHOLD = 0.3 FULL_DEGRADATION_THRESHOLD = 0.7 t = pygame.time.get_ticks() for y in range(self.height): for x in range(self.width): gid = self.map_data_gids[y][x] if gid == 0: continue degradation = self.degradation_levels[y][x] render_gid = gid if degradation > FULL_DEGRADATION_THRESHOLD: render_gid = self.full_degradation_gid_map.get(gid, gid) elif degradation > PARTIAL_DEGRADATION_THRESHOLD: render_gid = self.partial_degradation_gid_map.get(gid, gid) tile_surface = self.tiles.get(render_gid) or self.tiles.get(gid) if not tile_surface: continue world_x, world_y = self.array_to_world(x, y) screen_x, screen_y = self.world_to_screen(world_x, world_y) img_width, img_height = tile_surface.get_size() blit_x = screen_x - (img_width // 2) blit_y = screen_y - (img_height - tile_offset_y) # Wave ripple on shoreline tiles if (x, y) in self.shore_tiles: blit_y += int(math.sin(t / 700.0 + x * 2.7 + y * 1.9) * 3) screen.blit(tile_surface, (blit_x, blit_y)) # Depth haze: fade map tiles into sky at the far (top) edge if self._fade_surf is not None: screen.blit(self._fade_surf, (0, bg_height))
[docs] def to_isometric(self, cart_x, cart_y): """Convert Cartesian coordinates to isometric coordinates.""" iso_x = (cart_x - cart_y) * (self.tile_width / 2) iso_y = (cart_x + cart_y) * (self.tile_height / 4) return iso_x, iso_y
[docs] def get_forest_tile_percentage(self): """Calculates the percentage of tiles that are of the 'forest' type.""" total_land_tiles = 0 forest_tiles = 0 if not self.map_data_gids: return 0.0 for y in range(self.height): for x in range(self.width): gid = self.map_data_gids[y][x] if gid == 0: continue # Check the type from the class's tile mapping tile_type = self.tile_id_mapping.get(gid) # Calculate percentage based on usable land (not water) if tile_type and tile_type != 'water': total_land_tiles += 1 if tile_type == 'forest': forest_tiles += 1 if total_land_tiles == 0: return 0.0 return forest_tiles / total_land_tiles
[docs] def get_tile_properties(self, cart_x, cart_y): """Get properties of the tile at given Cartesian coordinates.""" iso_x, iso_y = self.to_isometric(cart_x, cart_y) array_x, array_y = self.world_to_array(iso_x, iso_y) if not (0 <= array_x < self.width and 0 <= array_y < self.height): return {} gid = self.map_data_gids[array_y][array_x] properties = self.tile_id_mapping.get(gid, {}) return properties if isinstance(properties, dict) else {}