contrib/tinks: add the game logic
authorAlexis Laferrière <alexis.laf@xymus.net>
Thu, 2 Jul 2015 00:13:49 +0000 (20:13 -0400)
committerAlexis Laferrière <alexis.laf@xymus.net>
Sun, 2 Aug 2015 16:25:23 +0000 (12:25 -0400)
Signed-off-by: Alexis Laferrière <alexis.laf@xymus.net>

contrib/tinks/src/game/framework.nit [new file with mode: 0644]
contrib/tinks/src/game/game.nit [new file with mode: 0644]
contrib/tinks/src/game/players.nit [new file with mode: 0644]
contrib/tinks/src/game/powerups.nit [new file with mode: 0644]
contrib/tinks/src/game/tanks.nit [new file with mode: 0644]
contrib/tinks/src/game/world.nit [new file with mode: 0644]

diff --git a/contrib/tinks/src/game/framework.nit b/contrib/tinks/src/game/framework.nit
new file mode 100644 (file)
index 0000000..49c74b0
--- /dev/null
@@ -0,0 +1,138 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Structure of a client/server game, based on turns, events, orders and rules
+module framework is serialize
+
+import geometry
+import realtime
+
+# Main game object containing the world, players and tanks
+#
+# The game object is shared by both the client and the server.
+# So a method using the game object as visitor is to be executed client-side.
+class TGame
+
+       # `Story`, or rule book, with all the stats for this game
+       var story = new Story
+
+       # `Clock` timing the elapsed time between turns
+       private var clock = new Clock is noserialize
+
+       # Tick count of the last turn (The first turn as a tick of 0)
+       var tick: Int = -1
+
+       # Execute the next turn and return it as a `TTurn`
+       #
+       # This is to be executed server-side only.
+       fun do_turn: TTurn
+       do
+               var dt = clock.lapse
+               tick += 1
+
+               var turn = new TTurn(self, tick, dt.to_f, dt.millisec)
+               return turn
+       end
+
+       # Apply `turn` locally by updating `tick` and applying all events
+       #
+       # This is to be executed client-side only.
+       fun apply_turn(turn: TTurn)
+       do
+               tick = turn.tick
+               for event in turn.events do event.apply self
+       end
+end
+
+# A single turn of a `TGame`
+#
+# The turn object is created and populated by the server (using `TGame::do_turn`).
+# It is transmitted to the client but it cannot modify it.
+# So methods using the turn object as visitor are to be executed server-side.
+class TTurn
+
+       # `TGame` of which `self` is part of
+       var game: TGame
+
+       # Tick of this turn
+       var tick: Int
+
+       # Elapsed seconds since previous turn (as a `Float`)
+       var dts: Float
+
+       # Elapsed milliseconds since previous turn (as a `Int`)
+       var dt: Int
+
+       # `TEvent` that happened during this turn
+       #
+       # Events are added using `add`.
+       # This information is used to apply the turn client-side to update its game object.
+       # It is also used by effects on the UI and could be used by an AI.
+       var events: SequenceRead[TEvent] = new Array[TEvent]
+
+       # Add an `event` to `events` and apply it right away server-side
+       fun add(event: TEvent)
+       do
+               event.apply game
+               events.as(Array[TEvent]).add event
+       end
+end
+
+# Game event sent from the server to the client
+class TEvent
+
+       # Executed client-side to apply this event on the `game`
+       fun apply(game: TGame) do end
+end
+
+# An order sent from the client to the server
+class TOrder
+
+       # Apply order server-side on `turn`, usually spawns `GameEvent`
+       fun apply(turn: TTurn) do end
+end
+
+# An entity acting on each turn
+class TTurnable
+
+       # Act on `turn`
+       fun do_turn(turn: TTurn) do end
+end
+
+# A collection of `Rule` guiding the game
+#
+# Changing the story could (in theory) be enough to completely change the context of the game.
+# In _Tinks!_ however, we use this class lightly and fill it with static content.
+class Story
+end
+
+# Metadata of in game `Ruled` entities, keep their stats and assets in a single place
+class Rule
+
+       # `Story` to which `self` belongs
+       var story: Story
+end
+
+# A game entity with metadata in its `rule`
+class Ruled
+
+       # Kind of `Rule` for `rule`
+       type R: Rule
+
+       # Metadata of this entity
+       var rule: R
+end
+
+# Should the game show more information for debugging?
+fun debug: Bool do return false
diff --git a/contrib/tinks/src/game/game.nit b/contrib/tinks/src/game/game.nit
new file mode 100644 (file)
index 0000000..06a0a09
--- /dev/null
@@ -0,0 +1,18 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import tanks
+import players
+import world
+import powerups
diff --git a/contrib/tinks/src/game/players.nit b/contrib/tinks/src/game/players.nit
new file mode 100644 (file)
index 0000000..ac5bb76
--- /dev/null
@@ -0,0 +1,149 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Player related and tank spawning logic
+module players is serialize
+
+import tanks
+
+redef class TGame
+
+       # All the known players in the game
+       var players = new Array[Player]
+
+       redef fun do_turn
+       do
+               var turn = super
+               for player in players do
+                       player.do_turn turn
+               end
+               return turn
+       end
+end
+
+redef class TTurn
+
+       # Range around the center of the world (0, 0) where a tank can spawn
+       var spawn_range = 256.0
+
+       # Spawn a new tank for `player`
+       fun spawn_tank(player: Player)
+       do
+               var pos = new Pos(spawn_range.rand, spawn_range.rand)
+               var tank = new Tank(game.story.tanks.rand, pos, 2.0*pi.rand)
+
+               if tank.next_move_collisions(self).not_empty then
+                       # Clear the way
+                       game.world.explode(self, pos, 3)
+               end
+
+               add new TankSpawnEvent(tank, player)
+       end
+end
+
+# A player in the game
+class Player
+       super TTurnable
+
+       # Queue of orders to apply at the end of the turn
+       var orders = new Array[TOrder]
+
+       # The tank controlled by this player, if any
+       var tank: nullable Tank = null
+
+       # Index of the "unique" player stencil applied on all its tanks
+       var stencil_index: Int do
+               var counter = once new Ref[Int](0)
+               var val = counter.item
+               counter.item = (counter.item+1) % 4
+               return val
+       end
+
+       redef fun do_turn(turn)
+       do
+               # Apply orders if they are legal
+               for order in orders do
+                       if order.is_legal(turn.game, self) then
+                               order.apply turn
+                       else print "Server Warning: Order {order} is now illegal"
+               end
+
+               orders.clear
+       end
+end
+
+redef class Tank
+       # The player controlling this tank, if any
+       var player: nullable Player = null
+end
+
+redef class TOrder
+
+       # Is this order (still) legal?
+       #
+       # This is executed client-side.
+       fun is_legal(game: TGame, issed_by: Player): Bool do return true
+end
+
+redef abstract class TankOrder
+       redef fun is_legal(game, issed_by) do return issed_by == tank.player
+end
+
+# A request to spawn a new tank
+class SpawnTankOrder
+       super TOrder
+
+       # Requester
+       var player: Player
+
+       redef fun is_legal(turn, issed_by) do return issed_by == player and player.tank == null
+
+       redef fun apply(turn)
+       do
+               turn.spawn_tank player
+       end
+end
+
+# A new tank appeared
+class TankSpawnEvent
+       super TEvent
+
+       # The new tank
+       var tank: Tank
+
+       # The `tank` owner
+       var player: nullable Player
+
+       redef fun apply(game)
+       do
+               var player = player
+               if player != null then player.tank = tank
+               tank.player = player
+               game.tanks.add tank
+       end
+end
+
+redef class TankDeathEvent
+
+       redef fun apply(game)
+       do
+               super
+
+               # `player` has no tank anymore
+               var player = tank.player
+               if player != null then
+                       player.tank = null
+               end
+       end
+end
diff --git a/contrib/tinks/src/game/powerups.nit b/contrib/tinks/src/game/powerups.nit
new file mode 100644 (file)
index 0000000..2c7cd5b
--- /dev/null
@@ -0,0 +1,91 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Support for pickable powerups (only health for now)
+module powerups is serialize
+
+import tanks
+
+# A powerup item
+class Powerup
+       super Feature
+       redef type R: PowerupRule
+end
+
+# Metadata of a powerup item
+class PowerupRule
+       super FeatureRule
+
+       # Restore all health when picked up
+       var restore_health: Bool
+end
+
+redef class Story
+       # A powerup that restores all health
+       var health = new PowerupRule(self, 2, true)
+
+       # All `PowerupRule` in this story
+       var powerups: Array[PowerupRule] = [health]
+end
+
+redef class Tank
+
+       redef fun destroy(turn)
+       do
+               super
+
+               # Put a random powerup at the center of the old tank
+               var pos = new Pos(pos.x.floor+0.5, pos.y.floor+0.5)
+               var powerup = new Powerup(turn.game.story.powerups.rand, pos)
+               turn.add new FeatureChangeEvent(powerup, pos)
+
+               # Add some debris around it
+               var forward = new Pos((pos.x+heading.cos*1.1).floor+0.5, (pos.y+heading.sin*1.1).floor+0.5)
+               var backward = new Pos((pos.x-heading.cos*1.1).floor+0.5, (pos.y-heading.sin*1.1).floor+0.5)
+               turn.add new FeatureChangeEvent(new Feature(turn.game.story.debris, forward), forward)
+               turn.add new FeatureChangeEvent(new Feature(turn.game.story.debris, backward), backward)
+       end
+
+       # Intercept collision detection of "absorb" powerups
+       #
+       # This is a wee bit hackish.
+       # The collision detection on tank move can return a max of 4 items (1 per side).
+       # If there is powerups, they may have hidden other features.
+       # This could cause the tank to move over a feature and get stuck.
+       # This is not a big problem as the tank can open fire to liberate itself,
+       # or even simply go back as the speed is static.
+       redef fun next_move_collisions(turn)
+       do
+               var collisions = super
+               if collisions.is_empty then return collisions
+
+               for coll in collisions do if not coll isa Powerup then
+                       # An unavoidable collision
+                       return collisions
+               end
+
+               # Only powerups! absorb them
+               for powerup in collisions do
+                       if powerup isa Powerup then
+                               turn.add new FeatureChangeEvent(null, powerup.pos)
+
+                               if powerup.rule.restore_health then
+                                       turn.add new TankHealthChange(self, rule.max_health)
+                               end
+                       end
+               end
+
+               return new HashSet[Feature]
+       end
+end
diff --git a/contrib/tinks/src/game/tanks.nit b/contrib/tinks/src/game/tanks.nit
new file mode 100644 (file)
index 0000000..9d82965
--- /dev/null
@@ -0,0 +1,384 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Tank and tank turret related logic
+module tanks is serialize
+
+import world
+
+redef class TGame
+       # All of the tanks in game
+       var tanks = new TankSet
+
+       redef fun do_turn
+       do
+               var turn = super
+               for tank in tanks do tank.do_turn turn
+               return turn
+       end
+end
+
+# Stats of a tank kind, its config should be move to the `Story` if we want more than 1
+class TankRule
+       super Rule
+
+       # Width of this tank
+       var width: Float = 64.0/32.0
+
+       # Height of this tank
+       var length: Float = 100.0/32.0
+
+       # Maximum `health` this tank can normally have
+       var max_health = 4
+
+       # Maximum speed of this tank (in world coordinate units per seconds)
+       var max_speed = 2.0
+
+       # Turret turning speed (in radians per second)
+       var turret_turn_speed = 1.6
+
+       # Waiting time between shots can be fired
+       var turret_cooldown_time = 2.0
+
+       # Maximum `direction_heading` heading, may be double
+       var max_direction = 1.0
+end
+
+redef class Story
+       # The main (and only) tank configuration in this game
+       var tanks: Array[TankRule] = [new TankRule(self)]
+end
+
+# A tank!
+class Tank
+       super TTurnable
+       super Ruled
+       redef type R: TankRule
+
+       # In world `Pos` of this entity
+       var pos: Pos
+
+       # Orientation of this entity
+       var heading: Float
+
+       # The turret mounted on this tank
+       var turret = new Turret(self)
+
+       # Commanded direction
+       var direction_heading = 0.0
+
+       # Commanded speed
+       var direction_forwards = 0.0
+
+       # Health of this tank, out of `rule.max_health`
+       var health: Int = rule.max_health is lazy
+
+       redef fun do_turn(turn)
+       do
+               var collisions = next_move_collisions(turn)
+
+               if collisions.is_empty then
+                       var next = normal_next_pos(turn)
+                       self.pos = next.first
+                       self.heading = next.second
+               end
+
+               turret.do_turn turn
+
+               turn.add new TankMoveEvent(self, pos, direction_heading, direction_forwards, heading, turret.relative_heading)
+       end
+
+       # What would be the next position if not blocked by terrain features?
+       #
+       # Returns a couple of the new position and heading.
+       fun normal_next_pos(turn: TTurn): Couple[Pos, Float]
+       do
+               var heading = (heading + direction_heading * turn.dts).angle_normalize
+
+               var speed = direction_forwards * rule.max_speed * turn.dts
+               var pos = pos
+               if speed != 0.0 then
+                       pos += heading.to_vector(speed)
+               end
+
+               return new Couple[Pos, Float](pos, heading)
+       end
+
+       # Damage this tank, server-side
+       fun hit(turn: TTurn)
+       do
+               var damage = 1
+               var health = health - damage
+
+               if health <= 0 then
+                       destroy turn
+               else
+                       turn.add new TankHealthChange(self, health)
+               end
+       end
+
+       # Destroy this tank, server-side
+       fun destroy(turn: TTurn)
+       do
+               turn.add new TankDeathEvent(self)
+               turn.game.world.explode(turn, pos, 3)
+       end
+
+       # Collisions on the next move
+       fun next_move_collisions(turn: TTurn): HashSet[Feature]
+       do
+               var next = normal_next_pos(turn)
+               var features = new HashSet[Feature]
+
+               # Use the lines between the corners to detect collisions
+               var corners = corners_at(next)
+               var prev_corner = corners.last
+               for corner in corners do
+                       var feature = turn.game.world.first_collision(prev_corner, corner)
+                       if feature != null then features.add feature
+                       prev_corner = corner
+               end
+
+               return features
+       end
+
+       # Get the 4 corners at a `next` position
+       fun corners_at(next: Couple[Pos, Float]): Array[Pos]
+       do
+               var next_pos = next.first
+               var heading = next.second
+
+               var corners = new Array[Pos]
+
+               var hwy = rule.width/2.0 * (heading+pi/2.0).sin
+               var hwx = rule.width/2.0 * (heading+pi/2.0).cos
+               var hly = rule.length/2.0 * heading.sin
+               var hlx = rule.length/2.0 * heading.cos
+               corners.add new Pos(next_pos.x + hlx + hwx, next_pos.y + hly + hwy)
+               corners.add new Pos(next_pos.x + hlx - hwx, next_pos.y + hly - hwy)
+               corners.add new Pos(next_pos.x - hlx - hwx, next_pos.y - hly - hwy)
+               corners.add new Pos(next_pos.x - hlx + hwx, next_pos.y - hly + hwy)
+
+               return corners
+       end
+end
+
+# A tank turret
+class Turret
+       super TTurnable
+
+       # The `Tank` on which is mounted this turret
+       var tank: Tank
+
+       # Orientation of this turret relative to the tank
+       var relative_heading = 0.0
+
+       # Absolute orientation of this turret
+       fun heading: Float do return (tank.heading+relative_heading).angle_normalize
+
+       # Current target to aim for and fire upon
+       var target: nullable Pos = null
+
+       # Seconds left before the turret can open fire again
+       var cooldown = 0.0
+
+       redef fun do_turn(turn)
+       do
+               if cooldown > 0.0 then
+                       cooldown = cooldown - turn.dts
+
+                       if cooldown <= 0.0 then
+                               cooldown = 0.0
+
+                               # Notify clients
+                               turn.add new TurretReadyEvent(tank)
+                       end
+               end
+
+               var target = target
+               if target != null then
+
+                       var angle_to_target = tank.pos.atan2(target)
+                       var d = (heading - angle_to_target).angle_normalize
+
+                       var max_angle = tank.rule.turret_turn_speed * turn.dts
+                       if d.abs < max_angle then
+                               self.relative_heading = angle_to_target - tank.heading
+
+                               if cooldown == 0.0 then
+                                       # On target, fire
+                                       fire turn
+                                       self.target = null
+                               end
+                       else
+                               # Turn towards target
+                               if d < 0.0 then
+                                       self.relative_heading += max_angle
+                               else self.relative_heading -= max_angle
+                       end
+               end
+       end
+
+       # Open fire!
+       fun fire(turn: TTurn)
+       do
+               var dst = target
+               assert dst != null
+
+               # Is there something between the tank and the target?
+               var hit = turn.game.world.first_collision(tank.pos, dst)
+               if hit != null then dst = hit.pos
+
+               # Events!
+               turn.add new OpenFireEvent(tank)
+               turn.game.world.explode(turn, dst, 2)
+
+               # The turret need time to reload, cooldown!
+               cooldown = tank.rule.turret_cooldown_time
+       end
+end
+
+redef class World
+       redef fun explode(turn, center, force)
+       do
+               super
+
+               for tank in game.tanks do
+                       if tank.health == 0 then continue
+                       if center.dist(tank.pos) <= force.to_f + 1.0 then
+                               tank.hit turn
+                       end
+               end
+       end
+end
+
+# A collection of `Tank` that could be optimized
+class TankSet
+       super HashSet[Tank]
+end
+
+# A `tank` centric order
+abstract class TankOrder
+       super TOrder
+
+       # The `Tank` at the center of this order
+       var tank: Tank
+end
+
+# A command to change the behavior of `tank`
+class TankDirectionOrder
+       super TankOrder
+
+       # Desired direction, in [-1.0..1.0]
+       var direction_heading: Float
+
+       # Desired speed, in [-1.0..1.0]
+       var direction_forwards: Float
+
+       redef fun apply(game)
+       do
+               # TODO use events
+               var direction_heading = direction_heading
+               direction_heading = direction_heading.min(1.0).max(-1.0)
+               tank.direction_heading = direction_heading*tank.rule.max_direction
+
+               var direction_forwards = direction_forwards
+               direction_forwards = direction_forwards.min(1.0).max(-1.0)
+               tank.direction_forwards = direction_forwards*tank.rule.max_speed
+       end
+end
+
+# Order to aim and fire at `target`
+class AimAndFireOrder
+       super TankOrder
+
+       # Target for the turret
+       var target: Pos
+
+       redef fun apply(game)
+       do
+               tank.turret.target = target
+       end
+end
+
+# A `tank` centric event
+abstract class TankEvent
+       super TEvent
+
+       # The `Tank` at the center of this event
+       var tank: Tank
+end
+
+# `tank` opens fire
+class OpenFireEvent
+       super TankEvent
+end
+
+# The turret of `tank` is ready to open fire
+class TurretReadyEvent
+       super TankEvent
+end
+
+# `tank` has been destroyed
+class TankDeathEvent
+       super TankEvent
+
+       redef fun apply(game)
+       do
+               tank.health = 0
+               game.tanks.remove tank
+       end
+end
+
+# The health of `tank` changes to `new_health`
+class TankHealthChange
+       super TankEvent
+
+       # The new health for `tank`
+       var new_health: Int
+
+       redef fun apply(game)
+       do
+               tank.health = new_health
+       end
+end
+
+# A `tank` moved
+#
+# TODO this event is too big, divide in 2 or more and move more logic client-side
+class TankMoveEvent
+       super TankEvent
+
+       # The position
+       var pos: Pos
+
+       # The direction of the "wheels"
+       var direction_heading: Float
+
+       # The speed
+       var direction_forwards: Float
+
+       # Orientation of the tank
+       var tank_heading: Float
+
+       # Orientation of the turret
+       var turret_heading: Float
+
+       redef fun apply(game)
+       do
+               tank.pos = pos
+               tank.direction_heading = direction_heading
+               tank.direction_forwards = direction_forwards
+               tank.heading = tank_heading
+               tank.turret.relative_heading = turret_heading
+       end
+end
diff --git a/contrib/tinks/src/game/world.nit b/contrib/tinks/src/game/world.nit
new file mode 100644 (file)
index 0000000..a9e1844
--- /dev/null
@@ -0,0 +1,276 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Physical world logic
+module world is serialize
+
+import noise
+import more_collections
+
+import framework
+
+redef class TGame
+       # The physical world in which this game happens
+       var world = new World(self)
+end
+
+# A terrain features (a rock, a tree, etc.)
+class Feature
+       super Ruled
+       redef type R: FeatureRule
+
+       # Top-left corner of this feature
+       var pos: Pos
+end
+
+# Metadata for a `Feature`
+class FeatureRule
+       super Rule
+
+       # Strength to resist on `World::explode`
+       var strength: Int
+end
+
+# The physical world of the `game`
+class World
+       # Associated `TGame`
+       var game: TGame
+
+       # Past blast sites
+       var blast_sites = new Array[Pos]
+
+       private var mountain_map: Noise do
+               var map = new PerlinNoise
+               map.period = 10.0
+               return map
+       end
+
+       private var forest_map: Noise do
+               var map = new PerlinNoise
+               map.period = 24.0
+               return map
+       end
+
+       # Cache of discovered features, also keeps tracks of changes
+       private var features_cache = new FeatureMap
+
+       # Get the `Feature` at `x, y`, if any
+       fun [](x, y: Int): nullable Feature
+       do
+               if features_cache.has(x, y) then return features_cache[x, y]
+
+               # Generate a feature from the noise map
+               var pos = new Pos(x.to_f+0.5, y.to_f+0.5)
+
+               var feature = null
+               if mountain_map[x.to_f, y.to_f] > 0.55 then
+                       feature = new Feature(game.story.rock, pos)
+               else if forest_map[x.to_f, y.to_f] > 0.5 then
+                       feature = new Feature(game.story.tree, pos)
+               end
+
+               # Update cache
+               features_cache[x, y] = feature
+
+               return feature
+       end
+
+       # Detect the first collision with a `Feature` from `src` to `dst`
+       #
+       # This is the main collision detection method used by tanks shots, and at tank movement.
+       # The idea is to check all cases between `src` and `dst` and return the first feature found.
+       # Returns `null` if there is no obstacle features.
+       #
+       # Example of the cases that would be checked between `s` and `d`:
+       #
+       # ~~~raw
+       # ................
+       # .s###...........
+       # ....######......
+       # .........####d..
+       # ................
+       # ~~~
+       fun first_collision(src, dst: Pos): nullable Feature
+       do
+               var going_left = dst.x < src.x
+               var angle = src.atan2(dst)
+               var slope = angle.tan
+
+               # Soften slopes approaching infinity
+               if slope > 100.0 then slope = 100.0
+               if slope < -100.0 then slope = -100.0
+
+               # For each column (over x) from src.x to dst.x
+               var x0 = src.x.floor.to_i
+               var x1 = dst.x.floor.to_i
+               for x in [x0 .. x1].smart_step do
+                       var dx = x.to_f - src.x
+                       var y0 = src.y + dx*slope
+                       var y1 = src.y + (dx+1.0)*slope
+
+                       var first = y0.floor.to_i
+                       var last = y1.floor.to_i
+                       if going_left then
+                               # Invert the first and last element of the range
+                               var swap = first
+                               first = last
+                               last = swap
+                       end
+
+                       # For each row (over y)
+                       # from where the line enters the column to where it leaves it
+                       for y in [first .. last].smart_step do
+                               if not y.in_between_floats(src.y, dst.y) then continue
+
+                               var feature = self[x.to_i, y]
+                               if feature != null then return feature
+                       end
+               end
+
+               return null
+       end
+
+       # Apply an explosion at `center` of the given `power`
+       fun explode(turn: TTurn, center: Pos, power: Int)
+       do
+               var x = center.x.floor.to_i
+               var y = center.y.floor.to_i
+               var range = [-power .. power]
+               var features = new Array[Feature]
+
+               for dx in range do
+                       for dy in range do
+                               var f = self[x+dx, y+dy]
+                               var force = (power-dx.abs) + (power-dy.abs)
+                               if f != null and f.rule.strength <= force then features.add f
+                       end
+               end
+
+               turn.add new ExplosionEvent(center, power, features)
+       end
+end
+
+# Map of features organized by their coordinates
+#
+# The naive implementation is using a `HashMap2`.
+# This class can be redefed with optimizations as needed.
+class FeatureMap
+       super HashMap2[Int, Int, nullable Feature]
+end
+
+redef class Story
+       # Forest tree
+       var tree = new FeatureRule(self, 2)
+
+       # Big rock
+       var rock = new FeatureRule(self, 3)
+
+       # Metallic debris
+       var debris = new FeatureRule(self, 4)
+end
+
+# An explosion
+class ExplosionEvent
+       super TEvent
+
+       # Center of the explosion
+       var pos: Pos
+
+       # Power of the blast
+       var power: Int
+
+       # All the features this explosion destroys
+       var destroyed_features: Array[Feature]
+
+       redef fun apply(game)
+       do
+               for feature in destroyed_features do
+                       game.world.features_cache[feature.pos.x.floor.to_i, feature.pos.y.floor.to_i] = null
+               end
+
+               game.world.blast_sites.add pos
+               if game.world.blast_sites.length > 100 then game.world.blast_sites.shift
+       end
+end
+
+# The feature at `pos` changes to `feature`
+class FeatureChangeEvent
+       super TEvent
+
+       # New `Feature`, if any
+       var feature: nullable Feature
+
+       # `Pos` of this change
+       var pos: Pos
+
+       redef fun apply(game)
+       do
+               game.world.features_cache[pos.x.floor.to_i, pos.y.floor.to_i] = feature
+       end
+end
+
+# ---
+# Services
+
+# Position in the world
+class Pos
+       super Point[Float]
+
+       # Add `self` to `other` and return the new position
+       fun +(other: Point[Float]): Pos
+       do
+               var nx = other.x.add(x)
+               var ny = other.y.add(y)
+               return new Pos(x.value_of(nx), y.value_of(ny))
+       end
+end
+
+redef universal Int
+       # Is `self` in between `a` and `b`?
+       #
+       # ~~~
+       # assert 1.in_between_floats(0.0, 2.0)
+       # assert 1.in_between_floats(2.0, 0.0)
+       # assert not 1.in_between_floats(2.0, 4.0)
+       # ~~~
+       fun in_between_floats(a, b: Float): Bool
+       do
+               var f = to_f
+               if a < b then return a.floor - 1.0 < f and f < b.ceil
+               return a.ceil > f and f > b.floor - 1.0
+       end
+end
+
+redef universal Float
+       # Get the vector with `self` as direction and the given `magnitude`
+       fun to_vector(magnitude: Float): Pos
+       do
+               return new Pos(cos*magnitude, sin*magnitude)
+       end
+end
+
+redef class Range[E]
+       # Step appropriately to go from `first` to `last`
+       #
+       # ~~~
+       # assert [1..3].smart_step.to_a == [1, 2, 3]
+       # assert [3..1].smart_step.to_a == [3, 2, 1]
+       # ~~~
+       fun smart_step: Iterator[E]
+       do
+               var step = 1
+               if first > last then step = -1
+               return self.step(step)
+       end
+end