From 0a25fa6dadb1560ed64c22fe12a6c3d8de567b84 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Thu, 12 Nov 2020 05:19:21 +0100 Subject: [PATCH] Add field of view. --- plomrogue/commands.py | 15 ++- plomrogue/game.py | 30 +++--- plomrogue/mapping.py | 139 +++++++++++++++++++++++++++- plomrogue/things.py | 20 +++- rogue_chat_curses.py | 32 ++++--- rogue_chat_nocanvas_monochrome.html | 7 +- 6 files changed, 213 insertions(+), 30 deletions(-) diff --git a/plomrogue/commands.py b/plomrogue/commands.py index 3e8a476..a3d1f4c 100644 --- a/plomrogue/commands.py +++ b/plomrogue/commands.py @@ -82,6 +82,9 @@ def cmd_TURN(game, n): cmd_TURN.argtypes = 'int:nonneg' def cmd_ANNOTATE(game, yx, msg, pw, connection_id): + player = game.get_thing(game.sessions[connection_id], False) + if player.fov_stencil[yx] == '.': + raise GameError('cannot annotate tile outside field of view') if not game.can_do_tile_with_pw(yx, pw): raise GameError('wrong password for tile') if msg == ' ': @@ -93,6 +96,9 @@ def cmd_ANNOTATE(game, yx, msg, pw, connection_id): cmd_ANNOTATE.argtypes = 'yx_tuple:nonneg string string' def cmd_PORTAL(game, yx, msg, pw, connection_id): + player = game.get_thing(game.sessions[connection_id], False) + if player.fov_stencil[yx] == '.': + raise GameError('cannot edit portal on tile outside field of view') if not game.can_do_tile_with_pw(yx, pw): raise GameError('wrong password for tile') if msg == ' ': @@ -114,9 +120,12 @@ def cmd_GOD_PORTAL(game, yx, msg): cmd_GOD_PORTAL.argtypes = 'yx_tuple:nonneg string' def cmd_GET_ANNOTATION(game, yx, connection_id): - annotation = '(none)'; - if yx in game.annotations: - annotation = game.annotations[yx] + player = game.get_thing(game.sessions[connection_id], False) + annotation = '(unknown)'; + if player.fov_stencil[yx] == '.': + annotation = '(none)'; + if yx in game.annotations: + annotation = game.annotations[yx] game.io.send('ANNOTATION %s %s' % (yx, quote(annotation))) cmd_GET_ANNOTATION.argtypes = 'yx_tuple:nonneg' diff --git a/plomrogue/game.py b/plomrogue/game.py index 9ba180f..0c17977 100755 --- a/plomrogue/game.py +++ b/plomrogue/game.py @@ -98,19 +98,25 @@ class Game(GameBase): def send_gamestate(self, connection_id=None): """Send out game state data relevant to clients.""" - def send_thing(thing): - self.io.send('THING_POS %s %s' % (thing.id_, t.position)) - if hasattr(thing, 'nickname'): - self.io.send('THING_NAME %s %s' % (thing.id_, quote(t.nickname))) - self.io.send('TURN ' + str(self.turn)) - for t in self.things: - send_thing(t) - self.io.send('MAP %s %s %s' % (self.get_map_geometry_shape(), - self.map_geometry.size, quote(self.map.terrain))) - self.io.send('MAP_CONTROL %s' % quote(self.map_control.terrain)) - for yx in self.portals: - self.io.send('PORTAL %s %s' % (yx, quote(self.portals[yx]))) + for c_id in self.sessions: + player = self.get_thing(self.sessions[c_id], create_unfound = False) + visible_terrain = player.fov_stencil_map(self.map) + self.io.send('FOV %s' % quote(player.fov_stencil.terrain), c_id) + self.io.send('MAP %s %s %s' % (self.get_map_geometry_shape(), + self.map_geometry.size, + quote(visible_terrain)), c_id) + visible_control = player.fov_stencil_map(self.map_control) + self.io.send('MAP_CONTROL %s' % quote(visible_control), c_id) + for t in [t for t in self.things + if player.fov_stencil[t.position] == '.']: + self.io.send('THING_POS %s %s' % (t.id_, t.position), c_id) + if hasattr(t, 'nickname'): + self.io.send('THING_NAME %s %s' % (t.id_, + quote(t.nickname)), c_id) + for yx in [yx for yx in self.portals + if player.fov_stencil[yx] == '.']: + self.io.send('PORTAL %s %s' % (yx, quote(self.portals[yx])), c_id) self.io.send('GAME_STATE_COMPLETE') def run_tick(self): diff --git a/plomrogue/mapping.py b/plomrogue/mapping.py index e0a59d8..4f2f5f9 100644 --- a/plomrogue/mapping.py +++ b/plomrogue/mapping.py @@ -56,6 +56,10 @@ class MapGeometryWithLeftRightMoves(MapGeometry): class MapGeometrySquare(MapGeometryWithLeftRightMoves): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fov_map_class = FovMapSquare + def move_UP(self, start_pos): return YX(start_pos.y - 1, start_pos.x) @@ -63,9 +67,12 @@ class MapGeometrySquare(MapGeometryWithLeftRightMoves): return YX(start_pos.y + 1, start_pos.x) - class MapGeometryHex(MapGeometryWithLeftRightMoves): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fov_map_class = FovMapHex + def move_UPLEFT(self, start_pos): start_indented = start_pos.y % 2 if start_indented: @@ -134,3 +141,133 @@ class Map(): width = self.size.x for y in range(self.size.y): yield (y, self.terrain[y * width:(y + 1) * width]) + + + +class FovMap(Map): + + def __init__(self, source_map, center): + self.source_map = source_map + self.size = self.source_map.size + self.fov_radius = (self.size.y / 2) - 0.5 + self.start_indented = True #source_map.start_indented + self.terrain = '?' * self.size_i + self.center = center + self[self.center] = '.' + self.shadow_cones = [] + self.geometry = self.geometry_class(self.size) + self.circle_out(self.center, self.shadow_process) + + def shadow_process(self, yx, distance_to_center, dir_i, dir_progress): + # Possible optimization: If no shadow_cones yet and self[yx] == '.', + # skip all. + CIRCLE = 360 # Since we'll float anyways, number is actually arbitrary. + + def correct_arm(arm): + if arm > CIRCLE: + arm -= CIRCLE + return arm + + def in_shadow_cone(new_cone): + for old_cone in self.shadow_cones: + if old_cone[0] <= new_cone[0] and \ + new_cone[1] <= old_cone[1]: + return True + # We might want to also shade tiles whose middle arm is inside a + # shadow cone for a darker FOV. Note that we then could not for + # optimization purposes rely anymore on the assumption that a + # shaded tile cannot add growth to existing shadow cones. + return False + + def merge_cone(new_cone): + import math + for old_cone in self.shadow_cones: + if new_cone[0] < old_cone[0] and \ + (new_cone[1] > old_cone[0] or + math.isclose(new_cone[1], old_cone[0])): + old_cone[0] = new_cone[0] + return True + if new_cone[1] > old_cone[1] and \ + (new_cone[0] < old_cone[1] or + math.isclose(new_cone[0], old_cone[1])): + old_cone[1] = new_cone[1] + return True + return False + + def eval_cone(cone): + if in_shadow_cone(cone): + return + self[yx] = '.' + if self.source_map[yx] == 'X': + unmerged = True + while merge_cone(cone): + unmerged = False + if unmerged: + self.shadow_cones += [cone] + + step_size = (CIRCLE/len(self.circle_out_directions)) / distance_to_center + number_steps = dir_i * distance_to_center + dir_progress + left_arm = correct_arm(step_size/2 + step_size*number_steps) + right_arm = correct_arm(left_arm + step_size) + + # Optimization potential: left cone could be derived from previous + # right cone. Better even: Precalculate all cones. + if right_arm < left_arm: + eval_cone([left_arm, CIRCLE]) + eval_cone([0, right_arm]) + else: + eval_cone([left_arm, right_arm]) + + def basic_circle_out_move(self, pos, direction): + """Move position pos into direction. Return whether still in map.""" + mover = getattr(self.geometry, 'move_' + direction) + pos = mover(pos) #, self.start_indented) + if pos.y < 0 or pos.x < 0 or \ + pos.y >= self.size.y or pos.x >= self.size.x: + return pos, False + return pos, True + + def circle_out(self, yx, f): + # Optimization potential: Precalculate movement positions. (How to check + # circle_in_map then?) + # Optimization potential: Precalculate what tiles are shaded by what tile + # and skip evaluation of already shaded tile. (This only works if tiles + # shading implies they completely lie in existing shades; otherwise we + # would lose shade growth through tiles at shade borders.) + circle_in_map = True + distance = 1 + yx = YX(yx.y, yx.x) + while circle_in_map: + if distance > self.fov_radius: + break + circle_in_map = False + yx, _ = self.basic_circle_out_move(yx, 'RIGHT') + for dir_i in range(len(self.circle_out_directions)): + for dir_progress in range(distance): + direction = self.circle_out_directions[dir_i] + yx, test = self.circle_out_move(yx, direction) + if test: + f(yx, distance, dir_i, dir_progress) + circle_in_map = True + distance += 1 + + + +class FovMapHex(FovMap): + circle_out_directions = ('DOWNLEFT', 'LEFT', 'UPLEFT', + 'UPRIGHT', 'RIGHT', 'DOWNRIGHT') + geometry_class = MapGeometryHex + + def circle_out_move(self, yx, direction): + return self.basic_circle_out_move(yx, direction) + + + +class FovMapSquare(FovMap): + circle_out_directions = (('DOWN', 'LEFT'), ('LEFT', 'UP'), + ('UP', 'RIGHT'), ('RIGHT', 'DOWN')) + geometry_class = MapGeometrySquare + + def circle_out_move(self, yx, direction): + yx, _ = self.basic_circle_out_move(yx, direction[0]) + return self.basic_circle_out_move(yx, direction[1]) diff --git a/plomrogue/things.py b/plomrogue/things.py index f066ae6..ee0d501 100644 --- a/plomrogue/things.py +++ b/plomrogue/things.py @@ -32,6 +32,7 @@ class ThingAnimate(Thing): super().__init__(*args, **kwargs) self.next_tasks = [] self.set_task('WAIT') + self._fov = None def set_task(self, task_name, args=()): task_class = self.game.tasks[task_name] @@ -51,6 +52,7 @@ class ThingAnimate(Thing): return None def proceed(self): + self._fov = None if self.task is None: self.task = self.get_next_task() return @@ -67,6 +69,23 @@ class ThingAnimate(Thing): self.game.changed = True self.task = self.get_next_task() + @property + def fov_stencil(self): + if self._fov: + return self._fov + fov_map_class = self.game.map_geometry.fov_map_class + self._fov = fov_map_class(self.game.map, self.position) + return self._fov + + def fov_stencil_map(self, map): + visible_terrain = '' + for i in range(self.fov_stencil.size_i): + if self.fov_stencil.terrain[i] == '.': + visible_terrain += map.terrain[i] + else: + visible_terrain += ' ' + return visible_terrain + class ThingPlayer(ThingAnimate): @@ -75,4 +94,3 @@ class ThingPlayer(ThingAnimate): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.nickname = 'undefined' - diff --git a/rogue_chat_curses.py b/rogue_chat_curses.py index 203f3ea..defc272 100755 --- a/rogue_chat_curses.py +++ b/rogue_chat_curses.py @@ -104,6 +104,10 @@ def cmd_MAP(game, geometry, size, content): } cmd_MAP.argtypes = 'string:map_geometry yx_tuple:pos string' +def cmd_FOV(game, content): + game.fov = content +cmd_FOV.argtypes = 'string' + def cmd_MAP_CONTROL(game, content): game.map_control_content = content cmd_MAP_CONTROL.argtypes = 'string' @@ -178,6 +182,7 @@ class Game(GameBase): self.register_command(cmd_GAME_ERROR) self.register_command(cmd_PLAY_ERROR) self.register_command(cmd_TASKS) + self.register_command(cmd_FOV) self.map_content = '' self.player_id = -1 self.info_db = {} @@ -261,6 +266,7 @@ class TUI: self.disconnected = True self.force_instant_connect = True self.input_lines = [] + self.fov = '' curses.wrapper(self.loop) def flash(self): @@ -418,18 +424,20 @@ class TUI: if not self.game.turn_complete: return pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x - info = 'TERRAIN: %s\n' % self.game.map_content[pos_i] - for t in self.game.things: - if t.position == self.explorer: - info += 'PLAYER @: %s\n' % t.name - if self.explorer in self.game.portals: - info += 'PORTAL: ' + self.game.portals[self.explorer] + '\n' - else: - info += 'PORTAL: (none)\n' - if self.explorer in self.game.info_db: - info += 'ANNOTATION: ' + self.game.info_db[self.explorer] - else: - info += 'ANNOTATION: waiting …' + info = 'outside field of view' + if self.game.fov[pos_i] == '.': + info = 'TERRAIN: %s\n' % self.game.map_content[pos_i] + for t in self.game.things: + if t.position == self.explorer: + info += 'PLAYER @: %s\n' % t.name + if self.explorer in self.game.portals: + info += 'PORTAL: ' + self.game.portals[self.explorer] + '\n' + else: + info += 'PORTAL: (none)\n' + if self.explorer in self.game.info_db: + info += 'ANNOTATION: ' + self.game.info_db[self.explorer] + else: + info += 'ANNOTATION: waiting …' lines = msg_into_lines_of_width(info, self.window_width) height_header = 2 for i in range(len(lines)): diff --git a/rogue_chat_nocanvas_monochrome.html b/rogue_chat_nocanvas_monochrome.html index bc30bde..66cb795 100644 --- a/rogue_chat_nocanvas_monochrome.html +++ b/rogue_chat_nocanvas_monochrome.html @@ -212,6 +212,8 @@ let server = { tui.init_keys(); game.map_size = parser.parse_yx(tokens[2]); game.map = tokens[3] + } else if (tokens[0] === 'FOV') { + game.fov = tokens[1] } else if (tokens[0] === 'MAP_CONTROL') { game.map_control = tokens[1] } else if (tokens[0] === 'GAME_STATE_COMPLETE') { @@ -679,8 +681,11 @@ let explorer = { server.send(["GET_ANNOTATION", unparser.to_yx(explorer.position)]); }, get_info: function() { - let info = ""; let position_i = this.position[0] * game.map_size[1] + this.position[1]; + if (game.fov[position_i] != '.') { + return 'outside field of view'; + }; + let info = ""; info += "TERRAIN: " + game.map[position_i] + "\n"; for (let t_id in game.things) { let t = game.things[t_id]; -- 2.30.2