Merge branch 'lighting' into 'master'

Lighting

Closes #27

See merge request ynerant/squirrel-battle!34
This commit is contained in:
ynerant 2021-01-08 02:01:13 +01:00
commit da47731faf
8 changed files with 260 additions and 25 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@
env/ env/
venv/ venv/
local/
.coverage .coverage
.pytest_cache/ .pytest_cache/

View File

@ -0,0 +1,41 @@
1 6
################################################################################
#..............................................................................#
#..#...........................................................................#
#...........#..................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
################################################################################

View File

@ -22,16 +22,21 @@ class MapDisplay(Display):
self.pack.tile_width * self.map.width + 1) self.pack.tile_width * self.map.width + 1)
def update_pad(self) -> None: def update_pad(self) -> None:
self.pad.resize(500, 500) for j in range(len(self.map.tiles)):
for i in range(self.map.height): for i in range(len(self.map.tiles[j])):
for j in range(self.map.width): if not self.map.seen_tiles[j][i]:
self.addstr(self.pad, i, j * self.pack.tile_width, continue
self.map.tiles[i][j].char(self.pack), fg, bg = self.map.tiles[j][i].visible_color(self.pack) if \
*self.map.tiles[i][j].color(self.pack)) self.map.visibility[j][i] else \
self.map.tiles[j][i].hidden_color(self.pack)
self.addstr(self.pad, j, self.pack.tile_width * i,
self.map.tiles[j][i].char(self.pack), fg, bg)
for e in self.map.entities: for e in self.map.entities:
self.addstr(self.pad, e.y, self.pack.tile_width * e.x, if self.map.visibility[e.y][e.x]:
self.pack[e.name.upper()], self.addstr(self.pad, e.y, self.pack.tile_width * e.x,
self.pack.entity_fg_color, self.pack.entity_bg_color) self.pack[e.name.upper()],
self.pack.entity_fg_color,
self.pack.entity_bg_color)
# Display Path map for debug purposes # Display Path map for debug purposes
# from squirrelbattle.entities.player import Player # from squirrelbattle.entities.player import Player

View File

@ -2,7 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import curses import curses
from typing import Any from typing import Any, Union, Tuple
class TexturePack: class TexturePack:
@ -13,10 +13,11 @@ class TexturePack:
name: str name: str
tile_width: int tile_width: int
tile_fg_color: int tile_fg_color: Union[int, Tuple[int, int, int]]
tile_bg_color: int tile_fg_visible_color: Union[int, Tuple[int, int, int]]
entity_fg_color: int tile_bg_color: Union[int, Tuple[int, int, int]]
entity_bg_color: int entity_fg_color: Union[int, Tuple[int, int, int]]
entity_bg_color: Union[int, Tuple[int, int, int]]
BODY_SNATCH_POTION: str BODY_SNATCH_POTION: str
BOMB: str BOMB: str
@ -58,9 +59,10 @@ class TexturePack:
TexturePack.ASCII_PACK = TexturePack( TexturePack.ASCII_PACK = TexturePack(
name="ascii", name="ascii",
tile_width=1, tile_width=1,
tile_fg_visible_color=(1000, 1000, 1000),
tile_fg_color=curses.COLOR_WHITE, tile_fg_color=curses.COLOR_WHITE,
tile_bg_color=curses.COLOR_BLACK, tile_bg_color=curses.COLOR_BLACK,
entity_fg_color=curses.COLOR_WHITE, entity_fg_color=(1000, 1000, 1000),
entity_bg_color=curses.COLOR_BLACK, entity_bg_color=curses.COLOR_BLACK,
BODY_SNATCH_POTION='S', BODY_SNATCH_POTION='S',
@ -86,17 +88,19 @@ TexturePack.ASCII_PACK = TexturePack(
TexturePack.SQUIRREL_PACK = TexturePack( TexturePack.SQUIRREL_PACK = TexturePack(
name="squirrel", name="squirrel",
tile_width=2, tile_width=2,
tile_fg_visible_color=(1000, 1000, 1000),
tile_fg_color=curses.COLOR_WHITE, tile_fg_color=curses.COLOR_WHITE,
tile_bg_color=curses.COLOR_BLACK, tile_bg_color=curses.COLOR_BLACK,
entity_fg_color=curses.COLOR_WHITE, entity_fg_color=(1000, 1000, 1000),
entity_bg_color=curses.COLOR_WHITE, entity_bg_color=(1000, 1000, 1000),
BODY_SNATCH_POTION='🔀', BODY_SNATCH_POTION='🔀',
BOMB='💣', BOMB='💣',
EMPTY=' ', EMPTY=' ',
EXPLOSION='💥', EXPLOSION='💥',
FLOOR='██', FLOOR='██',
LADDER=('🪜', curses.COLOR_WHITE, curses.COLOR_WHITE), LADDER=('🪜', curses.COLOR_WHITE, (1000, 1000, 1000),
curses.COLOR_WHITE, (1000, 1000, 1000)),
HAZELNUT='🌰', HAZELNUT='🌰',
HEART='💜', HEART='💜',
HEDGEHOG='🦔', HEDGEHOG='🦔',

View File

@ -17,7 +17,7 @@ class Player(InventoryHolder, FightingEntity):
strength: int = 5, intelligence: int = 1, charisma: int = 1, strength: int = 5, intelligence: int = 1, charisma: int = 1,
dexterity: int = 1, constitution: int = 1, level: int = 1, dexterity: int = 1, constitution: int = 1, level: int = 1,
current_xp: int = 0, max_xp: int = 10, inventory: list = None, current_xp: int = 0, max_xp: int = 10, inventory: list = None,
hazel: int = 42, *args, **kwargs) \ hazel: int = 42, vision: int = 5, *args, **kwargs) \
-> None: -> None:
super().__init__(name=name, maxhealth=maxhealth, strength=strength, super().__init__(name=name, maxhealth=maxhealth, strength=strength,
intelligence=intelligence, charisma=charisma, intelligence=intelligence, charisma=charisma,
@ -28,6 +28,7 @@ class Player(InventoryHolder, FightingEntity):
self.inventory = self.translate_inventory(inventory or []) self.inventory = self.translate_inventory(inventory or [])
self.paths = dict() self.paths = dict()
self.hazel = hazel self.hazel = hazel
self.vision = vision
def move(self, y: int, x: int) -> None: def move(self, y: int, x: int) -> None:
""" """
@ -38,6 +39,7 @@ class Player(InventoryHolder, FightingEntity):
self.map.currenty = y self.map.currenty = y
self.map.currentx = x self.map.currentx = x
self.recalculate_paths() self.recalculate_paths()
self.map.compute_visibility(self.y, self.x, self.vision)
def level_up(self) -> None: def level_up(self) -> None:
""" """

View File

@ -2,7 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from enum import Enum, auto from enum import Enum, auto
from math import sqrt from math import ceil, sqrt
from random import choice, randint from random import choice, randint
from typing import List, Optional, Any, Dict, Tuple from typing import List, Optional, Any, Dict, Tuple
from queue import PriorityQueue from queue import PriorityQueue
@ -32,6 +32,34 @@ class Logs:
self.messages = [] self.messages = []
class Slope():
X: int
Y: int
def __init__(self, y: int, x: int) -> None:
self.Y = y
self.X = x
def compare(self, other: "Slope") -> int:
y, x = other.Y, other.X
return self.Y * x - self.X * y
def __lt__(self, other: "Slope") -> bool:
return self.compare(other) < 0
def __eq__(self, other: "Slope") -> bool:
return self.compare(other) == 0
def __gt__(self, other: "Slope") -> bool:
return self.compare(other) > 0
def __le__(self, other: "Slope") -> bool:
return self.compare(other) <= 0
def __ge__(self, other: "Slope") -> bool:
return self.compare(other) >= 0
class Map: class Map:
""" """
The Map object represents a with its width, height The Map object represents a with its width, height
@ -43,6 +71,8 @@ class Map:
start_y: int start_y: int
start_x: int start_x: int
tiles: List[List["Tile"]] tiles: List[List["Tile"]]
visibility: List[List[bool]]
seen_tiles: List[List[bool]]
entities: List["Entity"] entities: List["Entity"]
logs: Logs logs: Logs
# coordinates of the point that should be # coordinates of the point that should be
@ -58,6 +88,10 @@ class Map:
self.start_y = start_y self.start_y = start_y
self.start_x = start_x self.start_x = start_x
self.tiles = tiles self.tiles = tiles
self.visibility = [[False for _ in range(len(tiles[0]))]
for _ in range(len(tiles))]
self.seen_tiles = [[False for _ in range(len(tiles[0]))]
for _ in range(len(tiles))]
self.entities = [] self.entities = []
self.logs = Logs() self.logs = Logs()
@ -147,7 +181,8 @@ class Map:
""" """
Puts randomly {count} entities on the map, only on empty ground tiles. Puts randomly {count} entities on the map, only on empty ground tiles.
""" """
for ignored in range(count): for _ignored in range(count):
y, x = 0, 0
while True: while True:
y, x = randint(0, self.height - 1), randint(0, self.width - 1) y, x = randint(0, self.height - 1), randint(0, self.width - 1)
tile = self.tiles[y][x] tile = self.tiles[y][x]
@ -157,6 +192,126 @@ class Map:
entity.move(y, x) entity.move(y, x)
self.add_entity(entity) self.add_entity(entity)
def compute_visibility(self, y: int, x: int, max_range: int) -> None:
"""
Sets the visible tiles to be the ones visible by an entity at point
(y, x), using a twaked shadow casting algorithm
"""
for line in self.visibility:
for i in range(len(line)):
line[i] = False
self.set_visible(0, 0, 0, (y, x))
for octant in range(8):
self.compute_visibility_octant(octant, (y, x), max_range, 1,
Slope(1, 1), Slope(0, 1))
def crop_top_visibility(self, octant: int, origin: Tuple[int, int],
x: int, top: Slope) -> int:
if top.X == 1:
top_y = x
else:
top_y = ceil(((x * 2 - 1) * top.Y + top.X) / (top.X * 2))
if self.is_wall(top_y, x, octant, origin):
top_y += top >= Slope(top_y * 2 + 1, x * 2) and not \
self.is_wall(top_y + 1, x, octant, origin)
else:
ax = x * 2
ax += self.is_wall(top_y + 1, x + 1, octant, origin)
top_y += top > Slope(top_y * 2 + 1, ax)
return top_y
def crop_bottom_visibility(self, octant: int, origin: Tuple[int, int],
x: int, bottom: Slope) -> int:
if bottom.Y == 0:
bottom_y = 0
else:
bottom_y = ceil(((x * 2 - 1) * bottom.Y + bottom.X)
/ (bottom.X * 2))
bottom_y += bottom >= Slope(bottom_y * 2 + 1, x * 2) and \
self.is_wall(bottom_y, x, octant, origin) and \
not self.is_wall(bottom_y + 1, x, octant, origin)
return bottom_y
def compute_visibility_octant(self, octant: int, origin: Tuple[int, int],
max_range: int, distance: int, top: Slope,
bottom: Slope) -> None:
for x in range(distance, max_range + 1):
top_y = self.crop_top_visibility(octant, origin, x, top)
bottom_y = self.crop_bottom_visibility(octant, origin, x, bottom)
was_opaque = -1
for y in range(top_y, bottom_y - 1, -1):
if x + y > max_range:
continue
is_opaque = self.is_wall(y, x, octant, origin)
is_visible = is_opaque\
or ((y != top_y or top > Slope(y * 4 - 1, x * 4 + 1))
and (y != bottom_y
or bottom < Slope(y * 4 + 1, x * 4 - 1)))
# is_visible = is_opaque\
# or ((y != top_y or top >= Slope(y, x))
# and (y != bottom_y or bottom <= Slope(y, x)))
if is_visible:
self.set_visible(y, x, octant, origin)
if x == max_range:
continue
if is_opaque and was_opaque == 0:
nx, ny = x * 2, y * 2 + 1
nx -= self.is_wall(y + 1, x, octant, origin)
if top > Slope(ny, nx):
if y == bottom_y:
bottom = Slope(ny, nx)
break
else:
self.compute_visibility_octant(
octant, origin, max_range, x + 1, top,
Slope(ny, nx))
elif y == bottom_y: # pragma: no cover
return
elif not is_opaque and was_opaque == 1:
nx, ny = x * 2, y * 2 + 1
nx += self.is_wall(y + 1, x + 1, octant, origin)
if bottom >= Slope(ny, nx): # pragma: no cover
return
top = Slope(ny, nx)
was_opaque = is_opaque
if was_opaque != 0:
break
@staticmethod
def translate_coord(y: int, x: int, octant: int,
origin: Tuple[int, int]) -> Tuple[int, int]:
ny, nx = origin
if octant == 0:
return ny - y, nx + x
elif octant == 1:
return ny - x, nx + y
elif octant == 2:
return ny - x, nx - y
elif octant == 3:
return ny - y, nx - x
elif octant == 4:
return ny + y, nx - x
elif octant == 5:
return ny + x, nx - y
elif octant == 6:
return ny + x, nx + y
elif octant == 7:
return ny + y, nx + x
def is_wall(self, y: int, x: int, octant: int,
origin: Tuple[int, int]) -> bool:
y, x = self.translate_coord(y, x, octant, origin)
return 0 <= y < len(self.tiles) and 0 <= x < len(self.tiles[0]) and \
self.tiles[y][x].is_wall()
def set_visible(self, y: int, x: int, octant: int,
origin: Tuple[int, int]) -> None:
y, x = self.translate_coord(y, x, octant, origin)
if 0 <= y < len(self.tiles) and 0 <= x < len(self.tiles[0]):
self.visibility[y][x] = True
self.seen_tiles[y][x] = True
def tick(self, p: Any) -> None: def tick(self, p: Any) -> None:
""" """
Triggers all entity events. Triggers all entity events.
@ -228,12 +383,21 @@ class Tile(Enum):
val = getattr(pack, self.name) val = getattr(pack, self.name)
return val[0] if isinstance(val, tuple) else val return val[0] if isinstance(val, tuple) else val
def color(self, pack: TexturePack) -> Tuple[int, int]: def visible_color(self, pack: TexturePack) -> Tuple[int, int]:
"""
Retrieve the tuple (fg_color, bg_color) of the current Tile
if it is visible.
"""
val = getattr(pack, self.name)
return (val[2], val[4]) if isinstance(val, tuple) else \
(pack.tile_fg_visible_color, pack.tile_bg_color)
def hidden_color(self, pack: TexturePack) -> Tuple[int, int]:
""" """
Retrieve the tuple (fg_color, bg_color) of the current Tile. Retrieve the tuple (fg_color, bg_color) of the current Tile.
""" """
val = getattr(pack, self.name) val = getattr(pack, self.name)
return (val[1], val[2]) if isinstance(val, tuple) else \ return (val[1], val[3]) if isinstance(val, tuple) else \
(pack.tile_fg_color, pack.tile_bg_color) (pack.tile_fg_color, pack.tile_bg_color)
def is_wall(self) -> bool: def is_wall(self) -> bool:

View File

@ -175,7 +175,7 @@ class TestEntities(unittest.TestCase):
self.assertEqual(item.y, 42) self.assertEqual(item.y, 42)
self.assertEqual(item.x, 42) self.assertEqual(item.x, 42)
# Wait for the explosion # Wait for the explosion
for ignored in range(5): for _ignored in range(5):
item.act(self.map) item.act(self.map)
self.assertTrue(hedgehog.dead) self.assertTrue(hedgehog.dead)
self.assertTrue(teddy_bear.dead) self.assertTrue(teddy_bear.dead)

View File

@ -4,7 +4,7 @@
import unittest import unittest
from squirrelbattle.display.texturepack import TexturePack from squirrelbattle.display.texturepack import TexturePack
from squirrelbattle.interfaces import Map, Tile from squirrelbattle.interfaces import Map, Tile, Slope
from squirrelbattle.resources import ResourceManager from squirrelbattle.resources import ResourceManager
@ -37,3 +37,21 @@ class TestInterfaces(unittest.TestCase):
self.assertFalse(Tile.WALL.can_walk()) self.assertFalse(Tile.WALL.can_walk())
self.assertFalse(Tile.EMPTY.can_walk()) self.assertFalse(Tile.EMPTY.can_walk())
self.assertRaises(ValueError, Tile.from_ascii_char, 'unknown') self.assertRaises(ValueError, Tile.from_ascii_char, 'unknown')
def test_slope(self) -> None:
"""
Test good behaviour of slopes (basically vectors, compared according to
the determinant)
"""
a = Slope(1, 1)
b = Slope(0, 1)
self.assertTrue(b < a)
self.assertTrue(b <= a)
self.assertTrue(a <= a)
self.assertTrue(a == a)
self.assertTrue(a > b)
self.assertTrue(a >= b)
# def test_visibility(self) -> None:
# m = Map.load(ResourceManager.get_asset_path("example_map_3.txt"))
# m.compute_visibility(1, 1, 50)