"""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.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 {}