From 237fca1a3d0114af13cceaa1507eac8d1308c86c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 25 Aug 2026 18:45:33 +0200 Subject: [PATCH] feat(sim): level model and loading --- game/sim/level.py | 247 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 game/sim/level.py diff --git a/game/sim/level.py b/game/sim/level.py new file mode 100644 index 0000000..d009e93 --- /dev/null +++ b/game/sim/level.py @@ -0,0 +1,247 @@ +import json +import os + +from ..constants import tileSize +from .geometry import Aabb +from .tiles import ( + AIR, + asciiToTile, + tileToAscii, + asciiEntities, + tileRegistry, +) + + +class EntitySpawn: + __slots__ = ("kind", "x", "y", "meta") + + def __init__(self, kind, x, y, meta=None): + self.kind = kind + self.x = float(x) + self.y = float(y) + self.meta = meta if meta is not None else {} + + def asDict(self): + record = {"kind": self.kind, "x": self.x, "y": self.y} + if self.meta: + record["meta"] = self.meta + return record + + +class PlatformSpawn: + __slots__ = ("x", "y", "w", "h", "toX", "toY", "speed", "oneWay") + + def __init__(self, x, y, w, h, toX, toY, speed, oneWay=False): + self.x = float(x) + self.y = float(y) + self.w = float(w) + self.h = float(h) + self.toX = float(toX) + self.toY = float(toY) + self.speed = float(speed) + self.oneWay = bool(oneWay) + + def asDict(self): + return { + "x": self.x, + "y": self.y, + "w": self.w, + "h": self.h, + "toX": self.toX, + "toY": self.toY, + "speed": self.speed, + "oneWay": self.oneWay, + } + + +class Level: + def __init__(self, name, collision, background=None, foreground=None): + self.name = name + self.collision = collision + self.rows = len(collision) + self.cols = len(collision[0]) if collision else 0 + self.background = background + self.foreground = foreground + self.playerSpawn = (tileSize, tileSize) + self.enemies = [] + self.items = [] + self.checkpoints = [] + self.platforms = [] + self.exit = None + + def pixelWidth(self): + return self.cols * tileSize + + def pixelHeight(self): + return self.rows * tileSize + + def inBounds(self, col, row): + return 0 <= col < self.cols and 0 <= row < self.rows + + def tileNameAt(self, col, row): + if row < 0 or row >= self.rows: + return AIR + if col < 0 or col >= self.cols: + return AIR + return self.collision[row][col] + + def defAt(self, col, row): + return tileRegistry[self.tileNameAt(col, row)] + + def solidAt(self, col, row): + if col < 0 or col >= self.cols: + return True + if row < 0 or row >= self.rows: + return False + return tileRegistry[self.collision[row][col]].solid + + def oneWayAt(self, col, row): + return self.defAt(col, row).oneWay + + def hazardAt(self, col, row): + return self.defAt(col, row).hazard + + def ladderAt(self, col, row): + return self.defAt(col, row).ladder + + def slopeAt(self, col, row): + return self.defAt(col, row).slope + + def hazardDamageAt(self, col, row): + return self.defAt(col, row).damage + + def rectFor(self, col, row): + return Aabb(col * tileSize, row * tileSize, tileSize, tileSize) + + def setTile(self, col, row, name): + if self.inBounds(col, row): + self.collision[row][col] = name + + def asDict(self): + record = { + "name": self.name, + "tileSize": tileSize, + "cols": self.cols, + "rows": self.rows, + "layers": {"collision": self.collision}, + "playerSpawn": list(self.playerSpawn), + "enemies": [spawn.asDict() for spawn in self.enemies], + "items": [spawn.asDict() for spawn in self.items], + "checkpoints": [list(point) for point in self.checkpoints], + "platforms": [platform.asDict() for platform in self.platforms], + "exit": list(self.exit) if self.exit else None, + } + if self.background is not None: + record["layers"]["background"] = self.background + if self.foreground is not None: + record["layers"]["foreground"] = self.foreground + return record + + +def parseAscii(name, rows): + width = max((len(row) for row in rows), default=0) + padded = [row.ljust(width) for row in rows] + collision = [[AIR for _ in range(width)] for _ in padded] + level = Level(name, collision) + for rowIndex, line in enumerate(padded): + for colIndex, char in enumerate(line): + if char in asciiToTile: + collision[rowIndex][colIndex] = asciiToTile[char] + elif char in asciiEntities: + kind = asciiEntities[char] + px = colIndex * tileSize + py = rowIndex * tileSize + if kind == "playerSpawn": + level.playerSpawn = (px, py) + elif kind == "checkpoint": + level.checkpoints.append((px, py)) + elif kind == "exit": + level.exit = (px, py) + elif kind in ("coin", "gem", "key", "heart"): + level.items.append(EntitySpawn(kind, px, py)) + else: + level.enemies.append(EntitySpawn(kind, px, py)) + if level.exit is None and level.checkpoints: + level.exit = level.checkpoints[-1] + return level + + +def levelToAscii(level): + lines = [] + entityAt = {} + for spawn in level.enemies + level.items: + col = int(round(spawn.x / tileSize)) + row = int(round(spawn.y / tileSize)) + entityAt[(col, row)] = spawn.kind + spx, spy = level.playerSpawn + entityAt[(int(round(spx / tileSize)), int(round(spy / tileSize)))] = "playerSpawn" + for point in level.checkpoints: + entityAt[(int(round(point[0] / tileSize)), int(round(point[1] / tileSize)))] = "checkpoint" + if level.exit: + entityAt[(int(round(level.exit[0] / tileSize)), int(round(level.exit[1] / tileSize)))] = "exit" + kindToChar = {value: key for key, value in asciiEntities.items()} + for rowIndex in range(level.rows): + chars = [] + for colIndex in range(level.cols): + key = (colIndex, rowIndex) + name = level.collision[rowIndex][colIndex] + if name != AIR: + chars.append(tileToAscii.get(name, "?")) + elif key in entityAt: + chars.append(kindToChar[entityAt[key]]) + else: + chars.append(" ") + lines.append("".join(chars).rstrip()) + return lines + + +def levelFromDict(record): + collision = record["layers"]["collision"] + background = record["layers"].get("background") + foreground = record["layers"].get("foreground") + level = Level(record.get("name", "unnamed"), collision, background, foreground) + level.playerSpawn = tuple(record.get("playerSpawn", (tileSize, tileSize))) + for enemy in record.get("enemies", []): + level.enemies.append(EntitySpawn(enemy["kind"], enemy["x"], enemy["y"], enemy.get("meta"))) + for item in record.get("items", []): + level.items.append(EntitySpawn(item["kind"], item["x"], item["y"], item.get("meta"))) + level.checkpoints = [tuple(point) for point in record.get("checkpoints", [])] + for platform in record.get("platforms", []): + level.platforms.append( + PlatformSpawn( + platform["x"], + platform["y"], + platform["w"], + platform["h"], + platform["toX"], + platform["toY"], + platform["speed"], + platform.get("oneWay", False), + ) + ) + exitPoint = record.get("exit") + level.exit = tuple(exitPoint) if exitPoint else None + return level + + +def saveLevelJson(level, path): + with open(path, "w", encoding="ascii") as handle: + json.dump(level.asDict(), handle, indent=2) + + +def loadLevelJson(path): + with open(path, "r", encoding="ascii") as handle: + record = json.load(handle) + return levelFromDict(record) + + +def loadLevel(source): + if isinstance(source, Level): + return source + if isinstance(source, dict): + return levelFromDict(source) + if isinstance(source, (list, tuple)): + return parseAscii("ascii", list(source)) + if isinstance(source, str) and os.path.isfile(source): + return loadLevelJson(source) + raise ValueError("unsupported level source: %r" % (source,))