feat: prototype level loading and world stepping
This commit is contained in:
1 parent
a7cedad3b7
commit
ed564f1e37
3 files changed
+374
No files matched your search
@@ -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
@@ -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
@@ -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)
|
||||
Reference in new issue
Block a user