594 lines
17 KiB
Python
594 lines
17 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
# SPDX-FileCopyrightText: 2023 Wojtek Porczyk <woju@hackerspace.pl>
|
|
|
|
import enum
|
|
import io
|
|
import math
|
|
import pathlib
|
|
import sys
|
|
|
|
import cv2
|
|
import numpy as np
|
|
import pygame
|
|
import matplotlib.path
|
|
import matplotlib.transforms
|
|
|
|
from . import (
|
|
assets,
|
|
ast,
|
|
blocks,
|
|
detector,
|
|
engine,
|
|
http_server,
|
|
parser,
|
|
)
|
|
|
|
|
|
def load_svg(path, size):
|
|
# Robot assets have width="100%" height="100%" attributes on svg, and pygame
|
|
# loads them as 1x1x32 surfaces, which is useless. This is adapted
|
|
# https://stackoverflow.com/a/69509545.
|
|
path = pathlib.Path(path)
|
|
width, height = size
|
|
with open(path, 'rb') as file:
|
|
data = file.read()
|
|
i = data.index(b'">') + 1 # this will find the end of <svg> opening tag
|
|
|
|
buffer = io.BytesIO()
|
|
buffer.write(data[:i])
|
|
buffer.write(f' width="{width}" height="{height}"'.encode('ascii'))
|
|
buffer.write(data[i:])
|
|
buffer.seek(0)
|
|
|
|
return pygame.image.load(buffer, path.name)
|
|
|
|
|
|
TILE_WIDTH = 128
|
|
|
|
|
|
class LevelField(enum.Enum):
|
|
ABYSS = ' '
|
|
FLOOR = '.' # aka EMPTY
|
|
WALL = '#'
|
|
ELEVATED = ':'
|
|
WATER = '~'
|
|
LIGHT_OFF = '?'
|
|
LIGHT_ON = '!'
|
|
|
|
|
|
_TILESET = {
|
|
# LevelField.FLOOR:
|
|
# load_svg(assets / 'tileset/401.svg', (TILE_WIDTH, TILE_WIDTH * 3//4)),
|
|
LevelField.FLOOR: load_svg(
|
|
assets / 'tileset/001.svg', (TILE_WIDTH, TILE_WIDTH * 3 // 4)
|
|
),
|
|
LevelField.WALL: load_svg(
|
|
assets / 'tileset/472.svg', (TILE_WIDTH, TILE_WIDTH * 11 // 4)
|
|
),
|
|
LevelField.ELEVATED: load_svg(
|
|
assets / 'tileset/477.svg', (TILE_WIDTH, TILE_WIDTH * 11 // 4)
|
|
),
|
|
LevelField.LIGHT_OFF: load_svg(
|
|
assets / 'tileset/051.svg', (TILE_WIDTH, TILE_WIDTH * 11 // 4)
|
|
),
|
|
LevelField.LIGHT_ON: load_svg(
|
|
assets / 'tileset/052.svg', (TILE_WIDTH, TILE_WIDTH * 11 // 4)
|
|
),
|
|
}
|
|
|
|
|
|
# 37c3 colours
|
|
# fmt: off
|
|
BLACK = ( 0, 0, 0) #000000
|
|
WHITE = (255, 255, 255) #ffffff
|
|
GREY1 = (217, 217, 217) #d9d9d9
|
|
GREY2 = (170, 170, 170) #aaaaaa
|
|
GREY3 = (122, 122, 122) #7a7a7a
|
|
GREY4 = ( 32, 32, 32) #202020
|
|
BLUE = ( 45, 66, 255) #2d42ff
|
|
BLUE2 = ( 11, 21, 117) #0b1575
|
|
RED = (222, 64, 64) #de4040
|
|
RED2 = ( 86, 16, 16) #561010
|
|
GREEN = (121, 255, 94) #79ff5e
|
|
GREEN2 = ( 43, 141, 24) #2b8d18
|
|
CYAN = ( 41, 255, 255) #29ffff
|
|
CYAN2 = ( 0, 107, 107) #006b6b
|
|
MAGENTA = (222, 55, 255) #de37ff
|
|
MAGENTA2 = (102, 0, 122) #66007a
|
|
YELLOW = (246, 246, 117) #f6f675
|
|
YELLOW2 = (117, 177, 1) #757501
|
|
# fmt: on
|
|
|
|
|
|
class Level:
|
|
def __init__(self, tiles):
|
|
self.tiles = tiles
|
|
|
|
# diagonal or "major" diagonal is from 0,0 to +inf,+inf
|
|
# minor diagonal is from 0,+inf to +inf,0
|
|
self.min_major_diag = self.min_minor_diag = float('+inf')
|
|
self.max_major_diag = self.max_minor_diag = float('-inf')
|
|
|
|
for y in range(len(tiles)):
|
|
for x in range(len(tiles[y])):
|
|
if self.tiles[y][x] == LevelField.ABYSS:
|
|
continue
|
|
major_diag, minor_diag = x + y, y - x
|
|
self.min_major_diag = min(self.min_major_diag, major_diag)
|
|
self.max_major_diag = max(self.max_major_diag, major_diag)
|
|
self.min_minor_diag = min(self.min_minor_diag, minor_diag)
|
|
self.max_minor_diag = max(self.max_minor_diag, minor_diag)
|
|
|
|
assert not any(
|
|
math.isinf(i)
|
|
for i in (
|
|
self.min_major_diag,
|
|
self.max_major_diag,
|
|
self.min_minor_diag,
|
|
self.max_minor_diag,
|
|
)
|
|
)
|
|
|
|
@classmethod
|
|
def from_file(cls, file):
|
|
tiles = []
|
|
hero = None
|
|
for y, line in enumerate(file):
|
|
line = line.rstrip('\n')
|
|
if not line:
|
|
break
|
|
tileline = []
|
|
for x, c in enumerate(line):
|
|
if c == '@':
|
|
if hero is not None:
|
|
raise ValueError('more than one hero')
|
|
hero = (x, y)
|
|
c = '.'
|
|
tileline.append(LevelField(c))
|
|
tiles.append(tileline)
|
|
return cls(tiles), hero
|
|
|
|
|
|
class Button:
|
|
def __init__(self, surface, font, x, y, width, height, text):
|
|
self.surface = surface
|
|
self.font = font
|
|
self.x = x
|
|
self.y = y
|
|
self.width = width
|
|
self.height = height
|
|
self.text = text
|
|
|
|
def draw(self):
|
|
radius = self.height / 2
|
|
pygame.draw.circle(
|
|
self.surface, WHITE, (self.x + radius, self.y + radius), radius
|
|
)
|
|
pygame.draw.rect(
|
|
self.surface,
|
|
WHITE,
|
|
pygame.Rect(
|
|
(self.x + radius, self.y), (self.width - radius, self.height)
|
|
),
|
|
)
|
|
font_surf = self.font.render(self.text, True, (0, 0, 0), WHITE)
|
|
self.surface.blit(
|
|
font_surf,
|
|
(
|
|
self.x + radius * 0.7,
|
|
self.y + (self.height - font_surf.get_height()) / 2,
|
|
),
|
|
)
|
|
|
|
|
|
class ButtonSprite(pygame.sprite.Sprite):
|
|
def __init__(self, x, y, width, height, text, font):
|
|
super().__init__()
|
|
|
|
self.image = pygame.Surface([width, height])
|
|
self.image.fill((0, 0, 0))
|
|
self.rect = self.image.get_rect()
|
|
self.rect.x, self.rect.y = x, y
|
|
|
|
radius = height / 2
|
|
pygame.draw.circle(self.image, WHITE, (radius, radius), radius)
|
|
pygame.draw.rect(
|
|
self.image,
|
|
WHITE,
|
|
pygame.Rect((radius, 0), (width - radius, height)),
|
|
)
|
|
text_surf = font.render(text, True, (0, 0, 0), WHITE)
|
|
self.image.blit(
|
|
text_surf, (radius * 0.7, (height - text_surf.get_height()) / 2)
|
|
)
|
|
|
|
|
|
class ButtonBar(pygame.sprite.AbstractGroup):
|
|
width = 300
|
|
height = 100
|
|
|
|
def __init__(self, screen, n=7):
|
|
super().__init__()
|
|
self._buttons = [None for i in range(n)]
|
|
|
|
screen_rect = screen.get_rect()
|
|
self.x = screen_rect.width - self.width
|
|
self.padding = (screen_rect.height - n * self.height) / (n + 1)
|
|
self.font = pygame.font.Font(assets / 'fonts/Rajdhani-Bold.otf', 60)
|
|
|
|
def __getitem__(self, key):
|
|
return self._buttons[key]
|
|
|
|
def set_button(self, index, text, _callback):
|
|
if self._buttons[index] is not None:
|
|
self._buttons[index].kill()
|
|
self.add(
|
|
ButtonSprite(
|
|
self.x, self.y(index), self.width, self.height, text, self.font
|
|
),
|
|
index=index,
|
|
)
|
|
|
|
def remove_button(self, i):
|
|
self._buttons[i].sprite.kill()
|
|
|
|
def y(self, index):
|
|
index %= len(self._buttons) # can be negative!
|
|
return self.padding * (index + 1) + self.height * index
|
|
|
|
def add(self, sprite, *, index):
|
|
if self._buttons[index] is not None:
|
|
self.remove(self._buttons[index])
|
|
super().add(sprite)
|
|
self._buttons[index] = sprite
|
|
|
|
def remove_internal(self, sprite):
|
|
self._buttons[self._buttons.index(sprite)] = None
|
|
super().remove_internal(self, sprite)
|
|
|
|
|
|
class Tile(pygame.sprite.Sprite):
|
|
def __init__(self, *args, image, midbottom, **kwds):
|
|
super().__init__(*args, **kwds)
|
|
self.image = image
|
|
self.rect = image.get_rect()
|
|
self.rect.midbottom = midbottom
|
|
|
|
|
|
class Robot(pygame.sprite.Sprite):
|
|
def __init__(self, *args, transform_level, images, pos, rotation, **kwds):
|
|
super().__init__(*args, **kwds)
|
|
self.images = images
|
|
self.transform = matplotlib.transforms.CompositeAffine2D(
|
|
transform_level, matplotlib.transforms.Affine2D().translate(0, -36)
|
|
)
|
|
self._rotation = None
|
|
self.set_rotation(rotation)
|
|
self.rect = self.image.get_rect()
|
|
self._pos = None
|
|
self.move(pos)
|
|
|
|
@property
|
|
def pos(self):
|
|
return self._pos
|
|
|
|
def move(self, pos):
|
|
self._pos = tuple(pos)
|
|
self.rect.midbottom = self.transform.transform(self._pos)
|
|
|
|
layer = pos_to_layer(*pos)
|
|
for group in self.groups():
|
|
try:
|
|
change_layer = group.change_layer
|
|
except AttributeError:
|
|
pass
|
|
else:
|
|
change_layer(self, layer)
|
|
|
|
@pos.setter
|
|
def pos(self, pos):
|
|
return self.move(pos)
|
|
|
|
def set_rotation(self, rotation):
|
|
self._rotation = rotation % len(self.images)
|
|
self.image = self.images[self._rotation]
|
|
|
|
def get_rotation(self):
|
|
return self._rotation
|
|
|
|
rotation = property(get_rotation, set_rotation)
|
|
|
|
|
|
def get_sprites_for_level(level, transform):
|
|
group = pygame.sprite.LayeredUpdates()
|
|
for y in range(len(level.tiles)):
|
|
for x in range(len(level.tiles[y])):
|
|
tile = level.tiles[y][x]
|
|
if tile == LevelField.ABYSS:
|
|
continue
|
|
group.add(
|
|
Tile(
|
|
image=_TILESET[level.tiles[y][x]],
|
|
midbottom=transform.transform((x, y)),
|
|
),
|
|
layer=pos_to_layer(x, y),
|
|
)
|
|
return group
|
|
|
|
|
|
# there's a stack of three transforms:
|
|
# 1. transform_isometric: base transform that skews in game (integral)
|
|
# coordinates into isometric view
|
|
# 2. transform_level: translates the level into the centre of the screen; this
|
|
# transform is applied to all the sprites
|
|
# 3. transform_robot: adds offset to the robot sprite (which is a bit special)
|
|
# in relation to the board
|
|
|
|
# (0,0) is to the top
|
|
TRANSFORM_ISOMETRIC_ORIGIN_TOP = matplotlib.transforms.Affine2D.from_values(
|
|
64, 32,
|
|
-64, 32,
|
|
0, 0,
|
|
) # fmt: skip
|
|
|
|
# (0,0) is to the left
|
|
TRANSFORM_ISOMETRIC_ORIGIN_LEFT = matplotlib.transforms.Affine2D.from_values(
|
|
64, -32,
|
|
64, 32,
|
|
0, 0,
|
|
) # fmt: skip
|
|
|
|
|
|
def pos_to_layer_origin_top(x, y):
|
|
return y + x
|
|
|
|
|
|
def pos_to_layer_origin_left(x, y):
|
|
return y - x
|
|
|
|
|
|
pos_to_layer = pos_to_layer_origin_left
|
|
|
|
|
|
def get_level_transform_for_surface_origin_top(surface, level):
|
|
half = surface.get_height() / 2
|
|
tx = max(
|
|
half
|
|
+ (level.min_minor_diag + level.max_minor_diag) / 2 * TILE_WIDTH / 2,
|
|
(level.max_minor_diag + 1) * TILE_WIDTH / 2,
|
|
)
|
|
ty = max(
|
|
half
|
|
- (level.min_major_diag + level.max_major_diag) / 2 * TILE_WIDTH / 4
|
|
+ TILE_WIDTH / 2,
|
|
(-level.min_major_diag) * TILE_WIDTH / 2 + TILE_WIDTH * 3 / 4,
|
|
)
|
|
|
|
return matplotlib.transforms.CompositeAffine2D(
|
|
TRANSFORM_ISOMETRIC_ORIGIN_TOP,
|
|
matplotlib.transforms.Affine2D().translate(tx, ty),
|
|
)
|
|
|
|
|
|
def get_level_transform_for_surface_origin_left(surface, level):
|
|
print(f'{level.min_major_diag=}')
|
|
print(f'{level.max_major_diag=}')
|
|
print(f'{level.min_minor_diag=}')
|
|
print(f'{level.max_minor_diag=}')
|
|
|
|
half = surface.get_height() / 2
|
|
|
|
tx = max(
|
|
half
|
|
- (level.min_major_diag + level.max_major_diag) / 2 * TILE_WIDTH / 2,
|
|
(level.min_major_diag + 1) * TILE_WIDTH / 2,
|
|
)
|
|
ty = max(
|
|
half
|
|
- (level.min_minor_diag + level.max_minor_diag) / 2 * TILE_WIDTH / 4
|
|
+ TILE_WIDTH / 2,
|
|
(level.min_minor_diag) * TILE_WIDTH / 2 + TILE_WIDTH * 3 / 4,
|
|
)
|
|
|
|
return matplotlib.transforms.CompositeAffine2D(
|
|
TRANSFORM_ISOMETRIC_ORIGIN_LEFT,
|
|
matplotlib.transforms.Affine2D().translate(tx, ty),
|
|
)
|
|
|
|
|
|
get_level_transform_for_surface = get_level_transform_for_surface_origin_left
|
|
|
|
|
|
class Game(engine.ExecEngine):
|
|
def __init__(self, *args, screen, level, hero, hero_rotation, **kwds):
|
|
super().__init__(*args, **kwds)
|
|
self.screen = screen
|
|
self.level = level
|
|
self.transform_level = get_level_transform_for_surface(screen, level)
|
|
self.robot = Robot(
|
|
transform_level=self.transform_level,
|
|
images=[
|
|
load_svg(assets / 'robot/robot01.svg', (96, 96)),
|
|
load_svg(assets / 'robot/robot02.svg', (96, 96)),
|
|
load_svg(assets / 'robot/robot03.svg', (96, 96)),
|
|
load_svg(assets / 'robot/robot04.svg', (96, 96)),
|
|
],
|
|
pos=hero,
|
|
rotation=hero_rotation,
|
|
)
|
|
self.sprites = get_sprites_for_level(
|
|
level, transform=self.transform_level
|
|
)
|
|
self.sprites.add(self.robot, layer=pos_to_layer(*self.robot.pos))
|
|
self.running = False
|
|
self.clock = pygame.time.Clock()
|
|
|
|
def loop_once(self, tick=1, *, _really_once=True):
|
|
self.process_events(once=_really_once)
|
|
self.screen.fill((0, 0, 0))
|
|
self.sprites.draw(self.screen)
|
|
|
|
height = self.screen.get_height()
|
|
half = height / 2
|
|
pygame.draw.line(self.screen, (255, 0, 0), (0, half), (height, half))
|
|
pygame.draw.line(self.screen, (255, 0, 0), (half, 0), (half, height))
|
|
|
|
pygame.display.flip()
|
|
if tick:
|
|
self.clock.tick(tick)
|
|
|
|
def loop(self):
|
|
self.running = True
|
|
|
|
while self.running:
|
|
self.loop_once(tick=False)
|
|
self.clock.tick(50)
|
|
|
|
def process_events(self, once):
|
|
for event in pygame.event.get():
|
|
if (
|
|
event.type == pygame.QUIT
|
|
or event.type == pygame.KEYDOWN
|
|
and event.key == pygame.K_q
|
|
):
|
|
if once:
|
|
sys.exit()
|
|
else:
|
|
self.running = False
|
|
|
|
def step(self, counter: int):
|
|
pos = list(self.robot.pos)
|
|
for _ in range(abs(counter)):
|
|
pos[self.robot.rotation % 2] += int(math.copysign(1, counter)) * (
|
|
(-1) ** (self.robot.rotation // 2)
|
|
)
|
|
self.robot.pos = pos
|
|
self.loop_once()
|
|
|
|
def turn_left(self):
|
|
self.robot.rotation -= 1
|
|
self.loop_once()
|
|
|
|
def turn_right(self):
|
|
self.robot.rotation += 1
|
|
self.loop_once()
|
|
|
|
def jump(self):
|
|
self.step(1)
|
|
# self.loop_once()
|
|
# raise NotImplementedError()
|
|
|
|
def pick_up(self):
|
|
raise NotImplementedError()
|
|
|
|
def place(self, direction: ast.Direction):
|
|
raise NotImplementedError()
|
|
|
|
def activate(self):
|
|
raise NotImplementedError()
|
|
|
|
def draw(self):
|
|
raise NotImplementedError()
|
|
|
|
def detect(self, direction: ast.Direction, obj: ast.Object):
|
|
raise NotImplementedError()
|
|
|
|
def execute_Programme(self, programme):
|
|
self.loop_once()
|
|
super().execute_Programme(programme)
|
|
self.loop()
|
|
|
|
|
|
w_przeręblu = [ # pylint: disable=non-ascii-name
|
|
(blocks.Token.BEGIN,),
|
|
(blocks.Token.REPEAT, blocks.Token.FOREVER),
|
|
(blocks.Token.TURN_RIGHT,),
|
|
(blocks.Token.END_BLOCK,),
|
|
(blocks.Token.END,),
|
|
] # fmt: skip
|
|
|
|
prog1_blocks = [
|
|
(blocks.Token.BEGIN,),
|
|
(blocks.Token.STEP, blocks.Token.DIGIT_1),
|
|
(blocks.Token.TURN_RIGHT,),
|
|
(blocks.Token.STEP, blocks.Token.DIGIT_1),
|
|
(blocks.Token.TURN_LEFT,),
|
|
(blocks.Token.STEP, blocks.Token.DIGIT_1),
|
|
(blocks.Token.TURN_RIGHT,),
|
|
(blocks.Token.STEP, blocks.Token.DIGIT_1),
|
|
(blocks.Token.TURN_LEFT,),
|
|
(blocks.Token.STEP, blocks.Token.DIGIT_1),
|
|
(blocks.Token.TURN_RIGHT,),
|
|
(blocks.Token.STEP, blocks.Token.DIGIT_1),
|
|
(blocks.Token.TURN_LEFT,),
|
|
(blocks.Token.END,),
|
|
]
|
|
|
|
|
|
def game_noninteractive():
|
|
pygame.init()
|
|
pygame.display.set_caption('tuxgo')
|
|
screen = pygame.display.set_mode(flags=pygame.FULLSCREEN)
|
|
|
|
with open(assets / 'levels/level-woju', encoding='utf-8') as file:
|
|
# with open(assets / 'levels/level-mem-sy') as file:
|
|
# with open(assets / 'levels/level-0-0') as file:
|
|
level, hero = Level.from_file(file)
|
|
if hero is None:
|
|
hero = (0, 0)
|
|
|
|
vm = Game(
|
|
screen=screen,
|
|
level=level,
|
|
hero=hero,
|
|
hero_rotation=0,
|
|
)
|
|
|
|
parser.parse(w_przeręblu).execute(vm)
|
|
# parser.parse(prog1_blocks).execute(vm)
|
|
|
|
pygame.quit()
|
|
|
|
|
|
def game_interactive():
|
|
det = detector.Detector()
|
|
|
|
pygame.init()
|
|
pygame.display.set_caption('tuxgo')
|
|
screen = pygame.display.set_mode(flags=pygame.FULLSCREEN)
|
|
|
|
with open(assets / 'levels/level-woju', encoding='utf-8') as file:
|
|
# with open(assets / 'levels/level-mem-sy') as file:
|
|
# with open(assets / 'levels/level-0-0') as file:
|
|
level, hero = Level.from_file(file)
|
|
if hero is None:
|
|
hero = (0, 0)
|
|
|
|
vm = Game(
|
|
screen=screen,
|
|
level=level,
|
|
hero=hero,
|
|
hero_rotation=0,
|
|
)
|
|
|
|
vm.loop_once(tick=0)
|
|
|
|
while True:
|
|
image = http_server.get_image_from_http_server()
|
|
image = np.asarray(bytearray(image), dtype=np.uint8)
|
|
frame = cv2.imdecode(image, 0)
|
|
analyser = detector.Analyser(det.detect_markers(frame))
|
|
programme = parser.parse(analyser.get_programme())
|
|
programme.execute(vm)
|
|
|
|
pygame.quit()
|
|
|
|
|
|
def main():
|
|
game_noninteractive()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|
|
|
|
# vim: tw=80 ts=4 sts=4 sw=4 et
|