feat: prototype level loading and world stepping

This commit is contained in:
dualfroz committed 2026-08-23 16:37:04 +02:00
1 parent a7cedad3b7
commit ed564f1e37
3 files changed
+374

No files matched your search

+84
View File
@@ -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)
+136
View File
@@ -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())
+154
View File
@@ -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)