622 lines
22 KiB
Python
622 lines
22 KiB
Python
import os
|
|
import tmx
|
|
import math
|
|
import pygame
|
|
from pygame import Rect
|
|
from pygame.math import Vector2
|
|
|
|
os.environ['SDL_VIDEO_CENTERED'] = '1'
|
|
|
|
###############################################################################
|
|
# Game State #
|
|
###############################################################################
|
|
|
|
class GameItem():
|
|
def __init__(self,state,position,tile):
|
|
self.state = state
|
|
self.status = "alive"
|
|
self.position = position
|
|
self.tile = tile
|
|
self.orientation = 0
|
|
|
|
class Unit(GameItem):
|
|
def __init__(self,state,position,tile):
|
|
super().__init__(state,position,tile)
|
|
self.weaponTarget = Vector2(0,0)
|
|
self.lastBulletEpoch = -100
|
|
|
|
class Bullet(GameItem):
|
|
def __init__(self,state,unit):
|
|
super().__init__(state,unit.position,Vector2(2,1))
|
|
self.unit = unit
|
|
self.startPosition = unit.position
|
|
self.endPosition = unit.weaponTarget
|
|
|
|
class GameState():
|
|
def __init__(self):
|
|
self.epoch = 0
|
|
self.worldSize = Vector2(16,10)
|
|
self.ground = [ [ Vector2(5,1) ] * 16 ] * 10
|
|
self.walls = [ [ None ] * 16 ] * 10
|
|
self.units = [ Unit(self,Vector2(8,9),Vector2(1,0)) ]
|
|
self.bullets = [ ]
|
|
self.bulletSpeed = 0.1
|
|
self.bulletRange = 4
|
|
self.bulletDelay = 5
|
|
self.observers = [ ]
|
|
|
|
@property
|
|
def worldWidth(self):
|
|
"""
|
|
Returns the world width as an integer
|
|
"""
|
|
return int(self.worldSize.x)
|
|
|
|
@property
|
|
def worldHeight(self):
|
|
"""
|
|
Returns the world height as an integer
|
|
"""
|
|
return int(self.worldSize.y)
|
|
|
|
def isInside(self,position):
|
|
"""
|
|
Returns true is position is inside the world
|
|
"""
|
|
return position.x >= 0 and position.x < self.worldWidth \
|
|
and position.y >= 0 and position.y < self.worldHeight
|
|
|
|
def findUnit(self,position):
|
|
"""
|
|
Returns the index of the first unit at position, otherwise None.
|
|
"""
|
|
for unit in self.units:
|
|
if int(unit.position.x) == int(position.x) \
|
|
and int(unit.position.y) == int(position.y):
|
|
return unit
|
|
return None
|
|
|
|
def findLiveUnit(self,position):
|
|
"""
|
|
Returns the index of the first live unit at position, otherwise None.
|
|
"""
|
|
unit = self.findUnit(position)
|
|
if unit is None or unit.status != "alive":
|
|
return None
|
|
return unit
|
|
|
|
def addObserver(self,observer):
|
|
"""
|
|
Add a game state observer.
|
|
All observer is notified when something happens (see GameStateObserver class)
|
|
"""
|
|
self.observers.append(observer)
|
|
|
|
def notifyUnitDestroyed(self,unit):
|
|
for observer in self.observers:
|
|
observer.unitDestroyed(unit)
|
|
|
|
class GameStateObserver():
|
|
def unitDestroyed(self,unit):
|
|
pass
|
|
|
|
###############################################################################
|
|
# Commands #
|
|
###############################################################################
|
|
|
|
class Command():
|
|
def run(self):
|
|
raise NotImplementedError()
|
|
|
|
class MoveCommand(Command):
|
|
"""
|
|
This command moves a unit in a given direction
|
|
"""
|
|
def __init__(self,state,unit,moveVector):
|
|
self.state = state
|
|
self.unit = unit
|
|
self.moveVector = moveVector
|
|
def run(self):
|
|
unit = self.unit
|
|
|
|
# Destroyed units can't move
|
|
if unit.status != "alive":
|
|
return
|
|
|
|
# Update unit orientation
|
|
if self.moveVector.x < 0:
|
|
unit.orientation = 90
|
|
elif self.moveVector.x > 0:
|
|
unit.orientation = -90
|
|
if self.moveVector.y < 0:
|
|
unit.orientation = 0
|
|
elif self.moveVector.y > 0:
|
|
unit.orientation = 180
|
|
|
|
# Compute new tank position
|
|
newPos = unit.position + self.moveVector
|
|
|
|
# Don't allow positions outside the world
|
|
if not self.state.isInside(newPos):
|
|
return
|
|
|
|
# Don't allow wall positions
|
|
if not self.state.walls[int(newPos.y)][int(newPos.x)] is None:
|
|
return
|
|
|
|
# Don't allow other unit positions
|
|
unitIndex = self.state.findUnit(newPos)
|
|
if not unitIndex is None:
|
|
return
|
|
|
|
unit.position = newPos
|
|
|
|
class TargetCommand(Command):
|
|
def __init__(self,state,unit,target):
|
|
self.state = state
|
|
self.unit = unit
|
|
self.target = target
|
|
def run(self):
|
|
self.unit.weaponTarget = self.target
|
|
|
|
class ShootCommand(Command):
|
|
def __init__(self,state,unit):
|
|
self.state = state
|
|
self.unit = unit
|
|
def run(self):
|
|
if self.unit.status != "alive":
|
|
return
|
|
if self.state.epoch-self.unit.lastBulletEpoch < self.state.bulletDelay:
|
|
return
|
|
self.unit.lastBulletEpoch = self.state.epoch
|
|
self.state.bullets.append(Bullet(self.state,self.unit))
|
|
|
|
class MoveBulletCommand(Command):
|
|
def __init__(self,state,bullet):
|
|
self.state = state
|
|
self.bullet = bullet
|
|
def run(self):
|
|
direction = (self.bullet.endPosition - self.bullet.startPosition).normalize()
|
|
newPos = self.bullet.position + self.state.bulletSpeed * direction
|
|
newCenterPos = newPos + Vector2(0.5,0.5)
|
|
# If the bullet goes outside the world, destroy it
|
|
if not self.state.isInside(newPos):
|
|
self.bullet.status = "destroyed"
|
|
return
|
|
# If the bullet goes towards the target cell, destroy it
|
|
if ((direction.x >= 0 and newPos.x >= self.bullet.endPosition.x) \
|
|
or (direction.x < 0 and newPos.x <= self.bullet.endPosition.x)) \
|
|
and ((direction.y >= 0 and newPos.y >= self.bullet.endPosition.y) \
|
|
or (direction.y < 0 and newPos.y <= self.bullet.endPosition.y)):
|
|
self.bullet.status = "destroyed"
|
|
return
|
|
# If the bullet is outside the allowed range, destroy it
|
|
if newPos.distance_to(self.bullet.startPosition) >= self.state.bulletRange:
|
|
self.bullet.status = "destroyed"
|
|
return
|
|
# If the bullet hits a unit, destroy the bullet and the unit
|
|
unit = self.state.findLiveUnit(newCenterPos)
|
|
if not unit is None and unit != self.bullet.unit:
|
|
self.bullet.status = "destroyed"
|
|
unit.status = "destroyed"
|
|
self.state.notifyUnitDestroyed(unit)
|
|
return
|
|
# Nothing happends, continue bullet trajectory
|
|
self.bullet.position = newPos
|
|
|
|
class DeleteDestroyedCommand(Command) :
|
|
def __init__(self,itemList):
|
|
self.itemList = itemList
|
|
def run(self):
|
|
newList = [ item for item in self.itemList if item.status == "alive" ]
|
|
self.itemList[:] = newList
|
|
|
|
|
|
class LoadLevelCommand(Command) :
|
|
def __init__(self,ui,fileName):
|
|
self.ui = ui
|
|
self.fileName = fileName
|
|
|
|
def decodeLayer(self,tileMap,layer):
|
|
"""
|
|
Decode layer and check layer properties
|
|
|
|
Returns the corresponding tileset
|
|
"""
|
|
if not isinstance(layer,tmx.Layer):
|
|
raise RuntimeError("Error in {}: invalid layer type".format(self.fileName))
|
|
if len(layer.tiles) != tileMap.width * tileMap.height:
|
|
raise RuntimeError("Error in {}: invalid tiles count".format(self.fileName))
|
|
|
|
# Guess which tileset is used by this layer
|
|
gid = None
|
|
for tile in layer.tiles:
|
|
if tile.gid != 0:
|
|
gid = tile.gid
|
|
break
|
|
if gid is None:
|
|
if len(tileMap.tilesets) == 0:
|
|
raise RuntimeError("Error in {}: no tilesets".format(self.fileName))
|
|
tileset = tileMap.tilesets[0]
|
|
else:
|
|
tileset = None
|
|
for t in tileMap.tilesets:
|
|
if gid >= t.firstgid and gid < t.firstgid+t.tilecount:
|
|
tileset = t
|
|
break
|
|
if tileset is None:
|
|
raise RuntimeError("Error in {}: no corresponding tileset".format(self.fileName))
|
|
|
|
# Check the tileset
|
|
if tileset.columns <= 0:
|
|
raise RuntimeError("Error in {}: invalid columns count".format(self.fileName))
|
|
if tileset.image.data is not None:
|
|
raise RuntimeError("Error in {}: embedded tileset image is not supported".format(self.fileName))
|
|
|
|
return tileset
|
|
|
|
def decodeArrayLayer(self,tileMap,layer):
|
|
"""
|
|
Create an array from a tileMap layer
|
|
"""
|
|
tileset = self.decodeLayer(tileMap,layer)
|
|
|
|
array = [ None ] * tileMap.height
|
|
for y in range(tileMap.height):
|
|
array[y] = [ None ] * tileMap.width
|
|
for x in range(tileMap.width):
|
|
tile = layer.tiles[x + y*tileMap.width]
|
|
if tile.gid == 0:
|
|
continue
|
|
lid = tile.gid - tileset.firstgid
|
|
if lid < 0 or lid >= tileset.tilecount:
|
|
raise RuntimeError("Error in {}: invalid tile id".format(self.fileName))
|
|
tileX = lid % tileset.columns
|
|
tileY = lid // tileset.columns
|
|
array[y][x] = Vector2(tileX,tileY)
|
|
|
|
return tileset, array
|
|
|
|
def decodeUnitsLayer(self,state,tileMap,layer):
|
|
"""
|
|
Create a list from a tileMap layer
|
|
"""
|
|
tileset = self.decodeLayer(tileMap,layer)
|
|
|
|
units = []
|
|
for y in range(tileMap.height):
|
|
for x in range(tileMap.width):
|
|
tile = layer.tiles[x + y*tileMap.width]
|
|
if tile.gid == 0:
|
|
continue
|
|
lid = tile.gid - tileset.firstgid
|
|
if lid < 0 or lid >= tileset.tilecount:
|
|
raise RuntimeError("Error in {}: invalid tile id".format(self.fileName))
|
|
tileX = lid % tileset.columns
|
|
tileY = lid // tileset.columns
|
|
unit = Unit(state,Vector2(x,y),Vector2(tileX,tileY))
|
|
units.append(unit)
|
|
|
|
return tileset, units
|
|
|
|
|
|
def run(self):
|
|
# Load map
|
|
if not os.path.exists(self.fileName):
|
|
raise RuntimeError("No file {}".format(self.fileName))
|
|
tileMap = tmx.TileMap.load(self.fileName)
|
|
|
|
# Check main properties
|
|
if tileMap.orientation != "orthogonal":
|
|
raise RuntimeError("Error in {}: invalid orientation".format(self.fileName))
|
|
|
|
if len(tileMap.layers) != 5:
|
|
raise RuntimeError("Error in {}: 5 layers are expected".format(self.fileName))
|
|
|
|
# World size
|
|
state = self.ui.gameState
|
|
state.worldSize = Vector2(tileMap.width,tileMap.height)
|
|
|
|
# Ground layer
|
|
tileset, array = self.decodeArrayLayer(tileMap,tileMap.layers[0])
|
|
cellSize = Vector2(tileset.tilewidth,tileset.tileheight)
|
|
state.ground[:] = array
|
|
imageFile = tileset.image.source
|
|
self.ui.layers[0].setTileset(cellSize,imageFile)
|
|
|
|
# Walls layer
|
|
tileset, array = self.decodeArrayLayer(tileMap,tileMap.layers[1])
|
|
if tileset.tilewidth != cellSize.x or tileset.tileheight != cellSize.y:
|
|
raise RuntimeError("Error in {}: tile sizes must be the same in all layers".format(self.fileName))
|
|
state.walls[:] = array
|
|
imageFile = tileset.image.source
|
|
self.ui.layers[1].setTileset(cellSize,imageFile)
|
|
|
|
# Units layer
|
|
tanksTileset, tanks = self.decodeUnitsLayer(state,tileMap,tileMap.layers[2])
|
|
towersTileset, towers = self.decodeUnitsLayer(state,tileMap,tileMap.layers[3])
|
|
if tanksTileset != towersTileset:
|
|
raise RuntimeError("Error in {}: tanks and towers tilesets must be the same".format(self.fileName))
|
|
if tanksTileset.tilewidth != cellSize.x or tanksTileset.tileheight != cellSize.y:
|
|
raise RuntimeError("Error in {}: tile sizes must be the same in all layers".format(self.fileName))
|
|
state.units[:] = tanks + towers
|
|
cellSize = Vector2(tanksTileset.tilewidth,tanksTileset.tileheight)
|
|
imageFile = tanksTileset.image.source
|
|
self.ui.layers[2].setTileset(cellSize,imageFile)
|
|
|
|
# Player units
|
|
self.ui.playerUnit = tanks[0]
|
|
|
|
# Explosions layers
|
|
tileset, array = self.decodeArrayLayer(tileMap,tileMap.layers[4])
|
|
if tileset.tilewidth != cellSize.x or tileset.tileheight != cellSize.y:
|
|
raise RuntimeError("Error in {}: tile sizes must be the same in all layers".format(self.fileName))
|
|
state.bullets.clear()
|
|
imageFile = tileset.image.source
|
|
self.ui.layers[3].setTileset(cellSize,imageFile)
|
|
|
|
# Window
|
|
windowSize = state.worldSize.elementwise() * cellSize
|
|
self.ui.window = pygame.display.set_mode((int(windowSize.x),int(windowSize.y)))
|
|
|
|
###############################################################################
|
|
# Rendering #
|
|
###############################################################################
|
|
|
|
class Layer(GameStateObserver):
|
|
def __init__(self,cellSize,imageFile):
|
|
self.cellSize = cellSize
|
|
self.texture = pygame.image.load(imageFile)
|
|
|
|
def setTileset(self,cellSize,imageFile):
|
|
self.cellSize = cellSize
|
|
self.texture = pygame.image.load(imageFile)
|
|
|
|
@property
|
|
def cellWidth(self):
|
|
return int(self.cellSize.x)
|
|
|
|
@property
|
|
def cellHeight(self):
|
|
return int(self.cellSize.y)
|
|
|
|
def unitDestroyed(self,unit):
|
|
pass
|
|
|
|
def renderTile(self,surface,position,tile,angle=None):
|
|
# Location on screen
|
|
spritePoint = position.elementwise()*self.cellSize
|
|
|
|
# Texture
|
|
texturePoint = tile.elementwise()*self.cellSize
|
|
textureRect = Rect(int(texturePoint.x), int(texturePoint.y), self.cellWidth, self.cellHeight)
|
|
|
|
# Draw
|
|
if angle is None:
|
|
surface.blit(self.texture,spritePoint,textureRect)
|
|
else:
|
|
# Extract the tile in a surface
|
|
textureTile = pygame.Surface((self.cellWidth,self.cellHeight),pygame.SRCALPHA)
|
|
textureTile.blit(self.texture,(0,0),textureRect)
|
|
# Rotate the surface with the tile
|
|
rotatedTile = pygame.transform.rotate(textureTile,angle)
|
|
# Compute the new coordinate on the screen, knowing that we rotate around the center of the tile
|
|
spritePoint.x -= (rotatedTile.get_width() - textureTile.get_width()) // 2
|
|
spritePoint.y -= (rotatedTile.get_height() - textureTile.get_height()) // 2
|
|
# Render the rotatedTile
|
|
surface.blit(rotatedTile,spritePoint)
|
|
|
|
def render(self,surface):
|
|
raise NotImplementedError()
|
|
|
|
class ArrayLayer(Layer):
|
|
def __init__(self,ui,imageFile,gameState,array,surfaceFlags=pygame.SRCALPHA):
|
|
super().__init__(ui,imageFile)
|
|
self.gameState = gameState
|
|
self.array = array
|
|
self.surface = None
|
|
self.surfaceFlags = surfaceFlags
|
|
|
|
def setTileset(self,cellSize,imageFile):
|
|
super().setTileset(cellSize,imageFile)
|
|
self.surface = None
|
|
|
|
def render(self,surface):
|
|
if self.surface is None:
|
|
self.surface = pygame.Surface(surface.get_size(),flags=self.surfaceFlags)
|
|
for y in range(self.gameState.worldHeight):
|
|
for x in range(self.gameState.worldWidth):
|
|
tile = self.array[y][x]
|
|
if not tile is None:
|
|
self.renderTile(self.surface,Vector2(x,y),tile)
|
|
surface.blit(self.surface,(0,0))
|
|
|
|
class UnitsLayer(Layer):
|
|
def __init__(self,ui,imageFile,gameState,units):
|
|
super().__init__(ui,imageFile)
|
|
self.gameState = gameState
|
|
self.units = units
|
|
|
|
def render(self,surface):
|
|
for unit in self.units:
|
|
self.renderTile(surface,unit.position,unit.tile,unit.orientation)
|
|
if unit.status == "alive":
|
|
size = unit.weaponTarget - unit.position
|
|
angle = math.atan2(-size.x,-size.y) * 180 / math.pi
|
|
self.renderTile(surface,unit.position,Vector2(0,6),angle)
|
|
|
|
class BulletsLayer(Layer):
|
|
def __init__(self,ui,imageFile,gameState,bullets):
|
|
super().__init__(ui,imageFile)
|
|
self.gameState = gameState
|
|
self.bullets = bullets
|
|
|
|
def render(self,surface):
|
|
for bullet in self.bullets:
|
|
if bullet.status == "alive":
|
|
self.renderTile(surface,bullet.position,bullet.tile,bullet.orientation)
|
|
|
|
class ExplosionsLayer(Layer):
|
|
def __init__(self,ui,imageFile):
|
|
super().__init__(ui,imageFile)
|
|
self.explosions = []
|
|
self.maxFrameIndex = 27
|
|
|
|
def add(self,position):
|
|
self.explosions.append({
|
|
'position': position,
|
|
'frameIndex': 0
|
|
})
|
|
|
|
def unitDestroyed(self,unit):
|
|
self.add(unit.position)
|
|
|
|
def render(self,surface):
|
|
for explosion in self.explosions:
|
|
frameIndex = math.floor(explosion['frameIndex'])
|
|
self.renderTile(surface,explosion['position'],Vector2(frameIndex,4))
|
|
explosion['frameIndex'] += 0.5
|
|
self.explosions = [ explosion for explosion in self.explosions if explosion['frameIndex'] < self.maxFrameIndex ]
|
|
|
|
|
|
|
|
###############################################################################
|
|
# User Interface #
|
|
###############################################################################
|
|
|
|
class UserInterface():
|
|
def __init__(self):
|
|
pygame.init()
|
|
|
|
# Game state
|
|
self.gameState = GameState()
|
|
|
|
# Rendering properties
|
|
self.cellSize = Vector2(64,64)
|
|
|
|
# Window
|
|
windowSize = self.gameState.worldSize.elementwise() * self.cellSize
|
|
self.window = pygame.display.set_mode((int(windowSize.x),int(windowSize.y)))
|
|
pygame.display.set_caption("Discover Python & Patterns - https://www.patternsgameprog.com")
|
|
pygame.display.set_icon(pygame.image.load("icon.png"))
|
|
|
|
# Layers
|
|
self.layers = [
|
|
ArrayLayer(self.cellSize,"ground.png",self.gameState,self.gameState.ground,0),
|
|
ArrayLayer(self.cellSize,"walls.png",self.gameState,self.gameState.walls),
|
|
UnitsLayer(self.cellSize,"units.png",self.gameState,self.gameState.units),
|
|
BulletsLayer(self.cellSize,"explosions.png",self.gameState,self.gameState.bullets),
|
|
ExplosionsLayer(self.cellSize,"explosions.png")
|
|
]
|
|
|
|
# All layers listen to game state events
|
|
for layer in self.layers:
|
|
self.gameState.addObserver(layer)
|
|
|
|
# Controls
|
|
self.playerUnit = self.gameState.units[0]
|
|
self.commands = [
|
|
LoadLevelCommand(self,"level2.tmx")
|
|
]
|
|
|
|
# Loop properties
|
|
self.clock = pygame.time.Clock()
|
|
self.running = True
|
|
|
|
@property
|
|
def cellWidth(self):
|
|
return int(self.cellSize.x)
|
|
|
|
@property
|
|
def cellHeight(self):
|
|
return int(self.cellSize.y)
|
|
|
|
def processInput(self):
|
|
# Pygame events (close, keyboard and mouse click)
|
|
moveVector = Vector2()
|
|
mouseClicked = False
|
|
for event in pygame.event.get():
|
|
if event.type == pygame.QUIT:
|
|
self.running = False
|
|
break
|
|
elif event.type == pygame.KEYDOWN:
|
|
if event.key == pygame.K_ESCAPE:
|
|
self.running = False
|
|
break
|
|
elif event.key == pygame.K_RIGHT:
|
|
moveVector.x = 1
|
|
elif event.key == pygame.K_LEFT:
|
|
moveVector.x = -1
|
|
elif event.key == pygame.K_DOWN:
|
|
moveVector.y = 1
|
|
elif event.key == pygame.K_UP:
|
|
moveVector.y = -1
|
|
elif event.type == pygame.MOUSEBUTTONDOWN:
|
|
mouseClicked = True
|
|
|
|
# Keyboard controls the moves of the player's unit
|
|
if moveVector.x != 0 or moveVector.y != 0:
|
|
self.commands.append(
|
|
MoveCommand(self.gameState,self.playerUnit,moveVector)
|
|
)
|
|
|
|
# Mouse controls the target of the player's unit
|
|
mousePos = pygame.mouse.get_pos()
|
|
targetCell = Vector2()
|
|
targetCell.x = mousePos[0] / self.cellWidth - 0.5
|
|
targetCell.y = mousePos[1] / self.cellHeight - 0.5
|
|
command = TargetCommand(self.gameState,self.playerUnit,targetCell)
|
|
self.commands.append(command)
|
|
|
|
# Shoot if left mouse was clicked
|
|
if mouseClicked:
|
|
self.commands.append(
|
|
ShootCommand(self.gameState,self.playerUnit)
|
|
)
|
|
|
|
# Other units always target the player's unit and shoot if close enough
|
|
for unit in self.gameState.units:
|
|
if unit != self.playerUnit:
|
|
self.commands.append(
|
|
TargetCommand(self.gameState,unit,self.playerUnit.position)
|
|
)
|
|
if unit.position.distance_to(self.playerUnit.position) <= self.gameState.bulletRange:
|
|
self.commands.append(
|
|
ShootCommand(self.gameState,unit)
|
|
)
|
|
|
|
# Bullets automatic movement
|
|
for bullet in self.gameState.bullets:
|
|
self.commands.append(
|
|
MoveBulletCommand(self.gameState,bullet)
|
|
)
|
|
|
|
# Delete any destroyed bullet
|
|
self.commands.append(
|
|
DeleteDestroyedCommand(self.gameState.bullets)
|
|
)
|
|
|
|
def update(self):
|
|
for command in self.commands:
|
|
command.run()
|
|
self.commands.clear()
|
|
self.gameState.epoch += 1
|
|
|
|
def render(self):
|
|
for layer in self.layers:
|
|
layer.render(self.window)
|
|
|
|
pygame.display.update()
|
|
|
|
def run(self):
|
|
while self.running:
|
|
self.processInput()
|
|
self.update()
|
|
self.render()
|
|
self.clock.tick(60)
|
|
|
|
userInterface = UserInterface()
|
|
userInterface.run()
|
|
|
|
pygame.quit()
|
|
|