home · contact · privacy
Add field of view.
authorChristian Heller <c.heller@plomlompom.de>
Thu, 12 Nov 2020 04:19:21 +0000 (05:19 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Thu, 12 Nov 2020 04:19:21 +0000 (05:19 +0100)
plomrogue/commands.py
plomrogue/game.py
plomrogue/mapping.py
plomrogue/things.py
rogue_chat_curses.py
rogue_chat_nocanvas_monochrome.html

index 3e8a476460687b873192d1cc0f54f03215b358e9..a3d1f4ce685cfabb9f6108ff8bd7f0f154e8434b 100644 (file)
@@ -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'
 
index 9ba180f4465c07ed1bc587761637b6f31fea793f..0c17977f2d6e2014570332db5e7ffd202e361edc 100755 (executable)
@@ -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):
index e0a59d8e225f56af79b0b516bb7f6e366de164a4..4f2f5f9e3b7c465bc05ed1409af9b318befb8d71 100644 (file)
@@ -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])
index f066ae660f685a0a8ba05503b1c75e6b4eb25de4..ee0d501be22258e69d287da986f8c1bf4d65e978 100644 (file)
@@ -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'
-
index 203f3eaefec758c879b3b7bc9edc451f2e385113..defc2724840ae41127b20748669288e8c5bbf73c 100755 (executable)
@@ -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)):
index bc30bde72b98106d7e1078a35baaaed261f196d8..66cb79584160760e95a2c4158503fbb53df8abc8 100644 (file)
@@ -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];