From ed564f1e375ad32dab5bef3e65282f8a2ee4a8fc Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 23 Aug 2026 16:37:04 +0200 Subject: [PATCH] feat: prototype level loading and world stepping --- game/level.py | 84 +++++++++++++++++++++++++++ game/smoke.py | 136 ++++++++++++++++++++++++++++++++++++++++++++ game/world.py | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 374 insertions(+) create mode 100644 game/level.py create mode 100644 game/smoke.py create mode 100644 game/world.py diff --git a/game/level.py b/game/level.py new file mode 100644 index 0000000..9e285ba --- /dev/null +++ b/game/level.py @@ -0,0 +1,84 @@ +from .constants import tileSize + +tileChars = { + "X": "ground", + "#": "brick", + "=": "platform", + "L": "pipeTL", + "R": "pipeTR", + "l": "pipeBL", + "r": "pipeBR", +} + +entityChars = {"p", "g", "o"} + +mainMap = [ + " ", + " ", + " o o o ", + " o o o ", + " o o ", + " o o o o o L R o o ", + " #### === l r ===== ", + " === ", + " p ##### l r g g ", + " L R l r ", + " g l r g ", + " l r ", + "XXXXXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXX", + "XXXXXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXX", +] + + +class Level: + def __init__(self, tiles, playerSpawn, enemySpawns, coinSpawns): + self.tiles = tiles + self.rows = len(tiles) + self.cols = len(tiles[0]) + self.playerSpawn = playerSpawn + self.enemySpawns = enemySpawns + self.coinSpawns = coinSpawns + + def pixelWidth(self): + return self.cols * tileSize + + def pixelHeight(self): + return self.rows * tileSize + + +def parseAscii(rows): + width = max(len(row) for row in rows) + padded = [row.ljust(width) for row in rows] + tiles = [["air"] * width for _ in padded] + playerSpawn = None + enemySpawns = [] + coinSpawns = [] + for y, row in enumerate(padded): + for x, char in enumerate(row): + if char in tileChars: + tiles[y][x] = tileChars[char] + elif char == "p": + playerSpawn = (x * tileSize, y * tileSize) + elif char == "g": + enemySpawns.append((x * tileSize, y * tileSize)) + elif char == "o": + coinSpawns.append((x * tileSize, y * tileSize)) + return Level(tiles, playerSpawn, enemySpawns, coinSpawns) + + +def buildLevel(): + return parseAscii(mainMap) + + +testMap = [ + " ", + " ", + " X ", + " p X ", + " X ", + "XXXXXXXXXX", +] + + +def buildTestLevel(): + return parseAscii(testMap) diff --git a/game/smoke.py b/game/smoke.py new file mode 100644 index 0000000..59f8f38 --- /dev/null +++ b/game/smoke.py @@ -0,0 +1,136 @@ +import math +import sys + +from .constants import tileSize +from .sim.world import World +from .sim.inputCommand import InputCommand +from .sim.builtinLevels import buildMeadow +from .sim.gameState import Game, MENU, PLAYING, PAUSED +from .sim import physics + + +def groundSurfaceY(level): + for row in range(level.rows): + for col in range(level.cols): + if level.solidAt(col, row): + return row * tileSize + return level.pixelHeight() + + +def penetratesSolid(world): + shrunk = world.player.aabb.inflated(-0.75, -0.75) + for tileBox in physics.solidTilesOverlapping(world.level, shrunk): + if shrunk.intersects(tileBox): + return True + return False + + +def driveInput(world, frame, heading): + player = world.player + jump = frame % 22 == 0 or player.onWallLeft or player.onWallRight + dash = frame % 75 == 0 + command = InputCommand( + left=heading < 0, + right=heading > 0, + jump=jump, + jumpHeld=(frame % 22) < 10, + dash=dash, + run=True, + ) + return command + + +def runSmoke(totalFrames=640): + world = World(buildMeadow()) + floorTop = groundSurfaceY(world.level) + heading = 1 + stats = { + "stomps": 0, + "maxBottom": world.player.bottom, + "minHp": world.player.hp, + "damageTaken": 0, + "coins": 0, + "framesRun": 0, + } + previousScoreFromStomp = 0 + previousHp = world.player.hp + + for frame in range(totalFrames): + if world.player.onWallRight and heading > 0: + heading = -1 + elif world.player.onWallLeft and heading < 0: + heading = 1 + if frame % 160 == 159: + heading *= -1 + + command = driveInput(world, frame, heading) + world.step(command) + stats["framesRun"] += 1 + + player = world.player + if not math.isfinite(player.x) or not math.isfinite(player.y): + raise AssertionError("player position not finite at frame %d" % frame) + if not (0 <= player.hp <= player.maxHp): + raise AssertionError("hp %d out of range at frame %d" % (player.hp, frame)) + if player.alive and world.active and penetratesSolid(world): + raise AssertionError("player penetrated solid tile at frame %d" % frame) + if player.alive and world.active and player.bottom > floorTop + tileSize * 4 + 2: + raise AssertionError("player fell through floor at frame %d bottom=%.1f" % (frame, player.bottom)) + if player.bottom > world.level.pixelHeight() + tileSize * 3: + raise AssertionError("player left world at frame %d" % frame) + + if player.hp < previousHp: + stats["damageTaken"] += previousHp - player.hp + previousHp = player.hp if player.alive else player.maxHp + stats["maxBottom"] = max(stats["maxBottom"], player.bottom) + stats["minHp"] = min(stats["minHp"], player.hp) + + stats["coins"] = world.coins + stats["enemiesLeft"] = len(world.enemies) + stats["enemiesStart"] = len(world.level.enemies) + stats["score"] = world.score + stats["sounds"] = world.audio.totalEmitted + stats["lives"] = world.lives + stats["gameOver"] = world.gameOver + stats["floorTop"] = floorTop + return stats + + +def checkFsm(): + game = Game([buildMeadow()]) + transitions = [] + assert game.mode == MENU + game.update(InputCommand(confirm=True)) + assert game.mode == PLAYING + transitions.append("menu->playing") + for _ in range(30): + game.update(InputCommand(right=True)) + game.update(InputCommand(pause=True)) + assert game.mode == PAUSED + transitions.append("playing->paused") + game.update(InputCommand(pause=True)) + assert game.mode == PLAYING + transitions.append("paused->playing") + return transitions + + +def main(): + stats = runSmoke() + fsm = checkFsm() + print("SMOKE RESULT") + print(" frames run : %d" % stats["framesRun"]) + print(" enemies start/left : %d / %d" % (stats["enemiesStart"], stats["enemiesLeft"])) + print(" score : %d" % stats["score"]) + print(" coins collected : %d" % stats["coins"]) + print(" damage taken (hp) : %d" % stats["damageTaken"]) + print(" min hp / lives : %d / %d" % (stats["minHp"], stats["lives"])) + print(" max player.bottom : %.1f (floorTop=%d)" % (stats["maxBottom"], stats["floorTop"])) + print(" sound events : %d" % stats["sounds"]) + print(" fsm transitions : %s" % ", ".join(fsm)) + print("INVARIANTS HELD: hp in range, no solid penetration, no fall-through, positions finite") + print("OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/game/world.py b/game/world.py new file mode 100644 index 0000000..f126cbc --- /dev/null +++ b/game/world.py @@ -0,0 +1,154 @@ +import pygame + +from .constants import ( + tileSize, + bufferWidth, + bufferHeight, + renderScale, + skyColor, + stompBounce, +) +from .art import buildSprites +from .entities import Player, Enemy, Coin + + +class MoveInput: + def __init__(self, left=False, right=False, jump=False): + self.left = left + self.right = right + self.jump = jump + + +class World: + def __init__(self, level, sprites=None): + self.level = level + self.tiles = level.tiles + self.cols = level.cols + self.rows = level.rows + self.sprites = sprites if sprites is not None else buildSprites() + self.player = Player(*level.playerSpawn) + self.enemies = [Enemy(x, y) for (x, y) in level.enemySpawns] + self.coins = [Coin(x, y) for (x, y) in level.coinSpawns] + self.score = 0 + self.coinCount = 0 + + def solidAt(self, col, row): + if col < 0 or col >= self.cols: + return True + if row < 0 or row >= self.rows: + return False + return self.tiles[row][col] != "air" + + def solidCellsOverlapping(self, box): + leftCol = box.left // tileSize + rightCol = box.right // tileSize + topRow = box.top // tileSize + bottomRow = box.bottom // tileSize + cells = [] + for row in range(topRow, bottomRow + 1): + for col in range(leftCol, rightCol + 1): + if self.solidAt(col, row): + cells.append(pygame.Rect(col * tileSize, row * tileSize, tileSize, tileSize)) + return cells + + def step(self, moveInput): + self.player.update(self, moveInput) + for enemy in self.enemies: + if enemy.alive: + enemy.update(self) + self.resolveEnemies() + self.resolveCoins() + + def resolveEnemies(self): + playerBox = self.player.rect() + for enemy in self.enemies: + if not enemy.alive: + continue + enemyBox = enemy.rect() + if not playerBox.colliderect(enemyBox): + continue + stomping = self.player.vy > 0 and (playerBox.bottom - enemyBox.top) < 10 + if stomping: + enemy.alive = False + self.player.vy = stompBounce + self.score += 100 + else: + self.player.alive = False + + def resolveCoins(self): + playerBox = self.player.rect() + for coin in self.coins: + if coin.collected: + continue + if playerBox.colliderect(coin.rect()): + coin.collected = True + self.coinCount += 1 + self.score += 200 + + def cameraOffset(self): + camX = int(self.player.x + self.player.width / 2 - bufferWidth / 2) + camY = int(self.player.y + self.player.height / 2 - bufferHeight / 2) + camX = max(0, min(camX, self.level.pixelWidth() - bufferWidth)) + camY = max(0, min(camY, self.level.pixelHeight() - bufferHeight)) + return camX, camY + + def drawBackground(self, buffer, camX): + buffer.fill(skyColor) + for hillX in (40, 220, 430, 620): + screenX = hillX - camX // 2 + pygame.draw.ellipse(buffer, (0, 168, 68), (screenX, bufferHeight - 46, 120, 70)) + pygame.draw.ellipse(buffer, (0, 200, 84), (screenX + 18, bufferHeight - 40, 84, 60)) + for cloudX in (90, 260, 470, 640): + screenX = cloudX - camX // 3 + pygame.draw.ellipse(buffer, (255, 255, 255), (screenX, 26, 46, 20)) + pygame.draw.ellipse(buffer, (255, 255, 255), (screenX + 18, 18, 40, 24)) + pygame.draw.ellipse(buffer, (255, 255, 255), (screenX + 34, 28, 34, 18)) + + def render(self): + buffer = pygame.Surface((bufferWidth, bufferHeight)) + camX, camY = self.cameraOffset() + self.drawBackground(buffer, camX) + + firstCol = max(0, camX // tileSize) + lastCol = min(self.cols, (camX + bufferWidth) // tileSize + 1) + firstRow = max(0, camY // tileSize) + lastRow = min(self.rows, (camY + bufferHeight) // tileSize + 1) + for row in range(firstRow, lastRow): + for col in range(firstCol, lastCol): + tile = self.tiles[row][col] + if tile == "air": + continue + buffer.blit(self.sprites[tile], (col * tileSize - camX, row * tileSize - camY)) + + for coin in self.coins: + if not coin.collected: + buffer.blit(self.sprites["coin"], (coin.x - camX, coin.y - camY)) + + for enemy in self.enemies: + if not enemy.alive: + continue + sprite = self.sprites["goomba"] + if enemy.direction > 0: + sprite = pygame.transform.flip(sprite, True, False) + buffer.blit(sprite, (int(enemy.x) - camX, int(enemy.y) - camY)) + + playerSprite = self.sprites["player"] + if self.player.facing < 0: + playerSprite = pygame.transform.flip(playerSprite, True, False) + buffer.blit(playerSprite, (int(self.player.x) - 2 - camX, int(self.player.y) - camY)) + + scaled = pygame.transform.scale( + buffer, (bufferWidth * renderScale, bufferHeight * renderScale) + ) + self.drawHud(scaled) + return scaled + + def drawHud(self, surf): + font = pygame.font.Font(None, 24) + scoreText = "SCORE %06d" % self.score + coinText = "COINS x%02d" % self.coinCount + for label, pos in ((scoreText, (10, 8)), (coinText, (surf.get_width() - 132, 8))): + shadow = font.render(label, True, (0, 0, 0)) + text = font.render(label, True, (255, 255, 255)) + surf.blit(shadow, (pos[0] + 1, pos[1] + 1)) + surf.blit(text, pos)