game_2d 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/README.md +18 -5
- data/bin/game_2d_client.rb +3 -2
- data/lib/game_2d/client_connection.rb +75 -20
- data/lib/game_2d/client_engine.rb +35 -34
- data/lib/game_2d/complex_move.rb +1 -1
- data/lib/game_2d/encryption.rb +35 -0
- data/lib/game_2d/entity.rb +47 -31
- data/lib/game_2d/entity/block.rb +2 -6
- data/lib/game_2d/entity/destination.rb +17 -0
- data/lib/game_2d/entity/owned_entity.rb +3 -2
- data/lib/game_2d/entity/pellet.rb +0 -8
- data/lib/game_2d/entity/teleporter.rb +46 -0
- data/lib/game_2d/game.rb +82 -23
- data/lib/game_2d/game_client.rb +360 -0
- data/lib/game_2d/game_space.rb +104 -18
- data/lib/game_2d/game_window.rb +17 -216
- data/lib/game_2d/menu.rb +3 -3
- data/lib/game_2d/message.rb +37 -0
- data/lib/game_2d/move/rise_up.rb +2 -2
- data/lib/game_2d/password_dialog.rb +44 -0
- data/lib/game_2d/player.rb +15 -18
- data/lib/game_2d/registerable.rb +1 -1
- data/lib/game_2d/serializable.rb +2 -13
- data/lib/game_2d/server_connection.rb +56 -24
- data/lib/game_2d/server_port.rb +14 -8
- data/lib/game_2d/transparency.rb +59 -0
- data/lib/game_2d/version.rb +1 -1
- data/lib/game_2d/zorder.rb +1 -1
- data/media/destination.png +0 -0
- data/media/destination.xcf +0 -0
- data/spec/client_engine_spec.rb +162 -71
- data/spec/game_space_spec.rb +8 -9
- metadata +11 -2
    
        data/README.md
    CHANGED
    
    | @@ -11,6 +11,13 @@ There's not much here yet, in terms of actual gameplay.  At this point I'm layin | |
| 11 11 | 
             
            * Entity positions and velocities are integers.  Apart from player input, all entities' behaviors are predictable.  This allows the client to make accurate predictions of the server state.
         | 
| 12 12 | 
             
            * New Entities are assigned IDs in order, again, to allow the client to predict which ID will be assigned to each new entity.
         | 
| 13 13 | 
             
            * Clients connect using REnet.  The handshaking process creates a Player object to represent the client.
         | 
| 14 | 
            +
            * Client and server use Diffie-Hellman key exchange to establish a shared secret.  This is used to encrypt sensitive information, rather like SSH (but over REnet).
         | 
| 15 | 
            +
              * Right now, the only sensitive information is the user's password.  This allows the server to remember who's who.
         | 
| 16 | 
            +
            * On startup, the client prompts the user to enter the password, which is encrypted and then sent to the server along with the player-name.
         | 
| 17 | 
            +
            * Server keeps a record of each player and their password.
         | 
| 18 | 
            +
              * Nothing else interesting here yet, but I fully expect to put an inventory here, once there is anything useful to carry around.
         | 
| 19 | 
            +
              * This is also where I plan to put authorization data - who's a god player (level editor) and who isn't.
         | 
| 20 | 
            +
              * Player data is persisted in ~/.game_2d/players/players as JSON text.
         | 
| 14 21 | 
             
            * Keyboard commands control the player:
         | 
| 15 22 | 
             
              * A and D, or LeftArrow and RightArrow: Hold down to accelerate.
         | 
| 16 23 | 
             
              * LeftControl or RightControl: Hold down to brake (slow down).
         | 
| @@ -19,15 +26,19 @@ There's not much here yet, in terms of actual gameplay.  At this point I'm layin | |
| 19 26 | 
             
                * When not building: Flip (turn 180 degrees).
         | 
| 20 27 | 
             
                * When building: Rise up (ascend to the top of the built block).
         | 
| 21 28 | 
             
              * Left button: Fire a pellet.  Mouse click determines the direction and speed -- closer to the player means slower speed.
         | 
| 22 | 
            -
              * Right button:  | 
| 29 | 
            +
              * Right button: Grab an entity and drag it around.  Right-click again to release.  Grab is affected by snap-to-grid.  *(This will eventually become part of a level-editing mode.)*
         | 
| 30 | 
            +
              * Shifted right button, or B: Build a block where the mouse points.  *(This will eventually become part of a level-editing mode.)*
         | 
| 31 | 
            +
              * 1 through 7: Shortcuts for building Dirt, Brick, Cement, Steel, Unlikelium, Titanium, Teleporter.
         | 
| 32 | 
            +
            * Mousing over an entity displays some basic information about it.  *(This will eventually become part of a level-editing mode.)*
         | 
| 23 33 | 
             
            * A simple menu system is also available.  Left-click to select.  Esc returns to the top-level.
         | 
| 24 34 | 
             
            * The menus include a Save feature, which tells the server to save the level's state.
         | 
| 25 | 
            -
              * Levels are persisted in ~/.game_2d/<level name> as JSON text.
         | 
| 35 | 
            +
              * Levels are persisted in ~/.game_2d/levels/<level name> as JSON text.
         | 
| 26 36 | 
             
              * On subsequent startup with the same name, the level is loaded.
         | 
| 27 37 | 
             
            * The client and server communicate using JSON text.  This is undoubtedly inefficient, but aids in debugging at this early stage.
         | 
| 28 38 | 
             
            * Every frame is numbered by the server, and is referred to as a 'tick'.
         | 
| 29 39 | 
             
            * Player actions are both executed locally, and sent to the server.
         | 
| 30 40 | 
             
            * If other players are connected, the server sends everyone's actions to everyone else.
         | 
| 41 | 
            +
              * This is obviously insecure.  Validating user data is on my to-do list.
         | 
| 31 42 | 
             
            * Player actions are dated six ticks in the future, to give everyone a chance to get the message early.  That way everyone can execute the action at the same time.
         | 
| 32 43 | 
             
            * The server broadcasts the complete GameSpace four times a second, just in case a client gets out of sync.
         | 
| 33 44 | 
             
            * The client predicts the server state, but treats its own copy as advisory only -- the server's is definitive.
         | 
| @@ -44,11 +55,13 @@ The physics is (intentionally) pretty simple.  Unsupported objects fall, acceler | |
| 44 55 |  | 
| 45 56 | 
             
            When building a block, the longer you hold the S or DownArrow key, the more HP will be awarded to the block.  Hitting a block with a pellet reduces its HP, until it degrades to a lower form.  Dirt blocks with 1 HP will be entirely destroyed when hit.
         | 
| 46 57 |  | 
| 47 | 
            -
            Whether  | 
| 58 | 
            +
            Whether a block is supported depends exclusively on its immediate surroundings.  That means two blocks can support each other, and hang suspended.  For example, a dirt block sitting on a steel block will support each other.  A horizontal row of brick, capped at either end with cement, will also be free-standing.
         | 
| 48 59 |  | 
| 49 | 
            -
            Pellets are fired from the player's center, and are affected by gravity.  They damage whatever they hit first, and disappear.  Pellets won't hit the player who fired them.
         | 
| 60 | 
            +
            Pellets are fired from the player's center, and are affected by gravity.  They damage whatever they hit first, and disappear.  Pellets won't hit the player who fired them.  So far, only blocks can take damage; players are indestructible (but you know that will change).
         | 
| 50 61 |  | 
| 51 | 
            -
            There are also Titanium entities, which never fall, and are indestructible.  These are intended for use in designing levels with specific shapes.  They can only be created by using the menu to select Titanium, and then right-clicking.
         | 
| 62 | 
            +
            There are also Titanium entities, which never fall, and are indestructible.  These are intended for use in designing levels with specific shapes.  They can only be created by using the menu to select Titanium, and then shift-right-clicking.
         | 
| 63 | 
            +
             | 
| 64 | 
            +
            Teleporters never fall.  Their only action is to transfer their contents (anything close enough to intersect with their center) to their single destination-point.  This transfer affects location but not velocity.  Transfer doesn't happen if there is something blocking the exit point.
         | 
| 52 65 |  | 
| 53 66 | 
             
            A player is considered to be supported if their "feet" are touching a block.  Unsupported players will turn feet-downward and fall, until they land on something.  Supported players may slide left or right, and will follow any edges they reach -- going up and down walls, or hanging under ceilings.  The "flip" maneuver swaps head and feet; this becomes useful if the player's head is exactly touching another block.  At all other times, a flip leads to a fall (which can be useful too).
         | 
| 54 67 |  | 
    
        data/bin/game_2d_client.rb
    CHANGED
    
    | @@ -6,12 +6,13 @@ require 'game_2d/game_window' | |
| 6 6 | 
             
            opts = Trollop::options do
         | 
| 7 7 | 
             
              opt :name, "Player name", :type => :string, :required => true
         | 
| 8 8 | 
             
              opt :hostname, "Hostname of server", :type => :string, :required => true
         | 
| 9 | 
            -
              opt :port, "Port number", :default => DEFAULT_PORT
         | 
| 9 | 
            +
              opt :port, "Port number", :default => GameClient::DEFAULT_PORT
         | 
| 10 | 
            +
              opt :key_size, "Key size in bits", :default => 1024
         | 
| 10 11 | 
             
              opt :profile, "Turn on profiling", :type => :boolean
         | 
| 11 12 | 
             
              opt :debug_traffic, "Debug network traffic", :type => :boolean
         | 
| 12 13 | 
             
            end
         | 
| 13 14 |  | 
| 14 15 | 
             
            $debug_traffic = opts[:debug_traffic] || false
         | 
| 15 16 |  | 
| 16 | 
            -
            window = GameWindow.new( opts | 
| 17 | 
            +
            window = GameWindow.new( opts )
         | 
| 17 18 | 
             
            window.show
         | 
| @@ -1,11 +1,17 @@ | |
| 1 1 | 
             
            require 'renet'
         | 
| 2 2 | 
             
            require 'json'
         | 
| 3 | 
            +
            require 'base64'
         | 
| 4 | 
            +
            require 'openssl'
         | 
| 3 5 | 
             
            require 'game_2d/hash'
         | 
| 6 | 
            +
            require 'game_2d/encryption'
         | 
| 4 7 |  | 
| 5 8 | 
             
            # The client creates one of these.
         | 
| 6 9 | 
             
            # It is then used for all communication with the server.
         | 
| 7 10 |  | 
| 8 11 | 
             
            class ClientConnection
         | 
| 12 | 
            +
              include Encryption
         | 
| 13 | 
            +
              include Base64
         | 
| 14 | 
            +
             | 
| 9 15 | 
             
              attr_reader :player_name
         | 
| 10 16 | 
             
              attr_accessor :engine
         | 
| 11 17 |  | 
| @@ -14,17 +20,31 @@ class ClientConnection | |
| 14 20 | 
             
              # the fleet
         | 
| 15 21 | 
             
              ACTION_DELAY = 6 # 1/10 of a second
         | 
| 16 22 |  | 
| 17 | 
            -
              def initialize(host, port, game, player_name, timeout=2000)
         | 
| 23 | 
            +
              def initialize(host, port, game, player_name, key_size, timeout=2000)
         | 
| 18 24 | 
             
                # remote host address, remote host port, channels, download bandwidth, upload bandwidth
         | 
| 19 25 | 
             
                @socket = _create_connection(host, port, 2, 0, 0)
         | 
| 20 | 
            -
                @game = | 
| 21 | 
            -
             | 
| 26 | 
            +
                @host, @port, @game, @player_name, @key_size, @timeout =
         | 
| 27 | 
            +
                 host,  port,  game,  player_name,  key_size,  timeout
         | 
| 22 28 |  | 
| 23 29 | 
             
                @socket.on_connection(method(:on_connect))
         | 
| 24 30 | 
             
                @socket.on_disconnection(method(:on_close))
         | 
| 25 31 | 
             
                @socket.on_packet_receive(method(:on_packet))
         | 
| 26 32 |  | 
| 27 | 
            -
                @ | 
| 33 | 
            +
                @dh = @password_hash = nil
         | 
| 34 | 
            +
              end
         | 
| 35 | 
            +
             | 
| 36 | 
            +
              def start(password_hash)
         | 
| 37 | 
            +
                @password_hash = password_hash
         | 
| 38 | 
            +
                Thread.new do
         | 
| 39 | 
            +
                  @game.display_message! "Establishing encryption (#{@key_size}-bit)..."
         | 
| 40 | 
            +
                  @dh = OpenSSL::PKey::DH.new(@key_size)
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                  # Connect to server and kick off handshaking
         | 
| 43 | 
            +
                  # We will create our player object only after we've been accepted by the server
         | 
| 44 | 
            +
                  # and told our starting position
         | 
| 45 | 
            +
                  @game.display_message! "Connecting to #{@host}:#{@port} as #{@player_name}"
         | 
| 46 | 
            +
                  @socket.connect(@timeout)
         | 
| 47 | 
            +
                end
         | 
| 28 48 | 
             
              end
         | 
| 29 49 |  | 
| 30 50 | 
             
              def _create_connection(*args)
         | 
| @@ -32,9 +52,22 @@ class ClientConnection | |
| 32 52 | 
             
              end
         | 
| 33 53 |  | 
| 34 54 | 
             
              def on_connect
         | 
| 35 | 
            -
                 | 
| 36 | 
            -
                 | 
| 37 | 
            -
             | 
| 55 | 
            +
                @game.display_message "Connected, logging in"
         | 
| 56 | 
            +
                send_record( { :handshake => {
         | 
| 57 | 
            +
                  :player_name => @player_name,
         | 
| 58 | 
            +
                  :dh_public_key => @dh.public_key.to_pem,
         | 
| 59 | 
            +
                  :client_public_key => @dh.pub_key.to_s
         | 
| 60 | 
            +
                } }, true) # send handshake reliably
         | 
| 61 | 
            +
              end
         | 
| 62 | 
            +
             | 
| 63 | 
            +
              def login(server_public_key)
         | 
| 64 | 
            +
                self.key = @dh.compute_key(OpenSSL::BN.new server_public_key)
         | 
| 65 | 
            +
                data, iv = encrypt(@password_hash)
         | 
| 66 | 
            +
                @password_hash = nil
         | 
| 67 | 
            +
                send_record(
         | 
| 68 | 
            +
                  :password_hash => strict_encode64(data),
         | 
| 69 | 
            +
                  :iv => strict_encode64(iv)
         | 
| 70 | 
            +
                )
         | 
| 38 71 | 
             
              end
         | 
| 39 72 |  | 
| 40 73 | 
             
              def on_close
         | 
| @@ -46,35 +79,40 @@ class ClientConnection | |
| 46 79 | 
             
                hash = JSON.parse(data).fix_keys
         | 
| 47 80 | 
             
                debug_packet('Received', hash)
         | 
| 48 81 |  | 
| 49 | 
            -
                pong = hash | 
| 50 | 
            -
                if pong
         | 
| 82 | 
            +
                if pong = hash.delete(:pong)
         | 
| 51 83 | 
             
                  stop = Time.now.to_f
         | 
| 52 84 | 
             
                  puts "Ping took #{stop - pong[:start]} seconds"
         | 
| 53 85 | 
             
                end
         | 
| 54 86 |  | 
| 55 | 
            -
                 | 
| 56 | 
            -
             | 
| 87 | 
            +
                if server_public_key = hash.delete(:server_public_key)
         | 
| 88 | 
            +
                  login(server_public_key)
         | 
| 89 | 
            +
                  return
         | 
| 90 | 
            +
                end
         | 
| 91 | 
            +
             | 
| 92 | 
            +
                # Leave :at_tick intact; add_delta will reuse it
         | 
| 93 | 
            +
                fail "No at_tick in #{hash.inspect}" unless at_tick = hash[:at_tick]
         | 
| 57 94 |  | 
| 58 | 
            -
                world = hash | 
| 59 | 
            -
             | 
| 95 | 
            +
                if world = hash.delete(:world)
         | 
| 96 | 
            +
                  @game.clear_message
         | 
| 60 97 | 
             
                  @engine.establish_world(world, at_tick)
         | 
| 61 98 | 
             
                end
         | 
| 62 99 |  | 
| 100 | 
            +
                you_are = hash.delete :you_are
         | 
| 101 | 
            +
                registry, highest_id = hash.delete(:registry), hash.delete(:highest_id)
         | 
| 102 | 
            +
             | 
| 63 103 | 
             
                delta_keys = [
         | 
| 64 104 | 
             
                  :add_players, :add_npcs, :delete_entities, :update_entities, :update_score, :move
         | 
| 65 105 | 
             
                ]
         | 
| 66 106 | 
             
                @engine.add_delta(hash) if delta_keys.any? {|k| hash.has_key? k}
         | 
| 67 107 |  | 
| 68 | 
            -
                you_are = hash[:you_are]
         | 
| 69 108 | 
             
                if you_are
         | 
| 70 109 | 
             
                  # The 'world' response includes deltas for add_players and add_npcs
         | 
| 71 110 | 
             
                  # Need to process those first, as one of the players is us
         | 
| 72 | 
            -
                  @engine. | 
| 111 | 
            +
                  @engine.apply_deltas(at_tick)
         | 
| 73 112 |  | 
| 74 113 | 
             
                  @engine.create_local_player you_are
         | 
| 75 114 | 
             
                end
         | 
| 76 115 |  | 
| 77 | 
            -
                registry, highest_id = hash[:registry], hash[:highest_id]
         | 
| 78 116 | 
             
                @engine.sync_registry(registry, highest_id, at_tick) if registry
         | 
| 79 117 | 
             
              end
         | 
| 80 118 |  | 
| @@ -83,16 +121,32 @@ class ClientConnection | |
| 83 121 | 
             
              end
         | 
| 84 122 |  | 
| 85 123 | 
             
              def send_move(move, args={})
         | 
| 86 | 
            -
                return unless move
         | 
| 124 | 
            +
                return unless move && online?
         | 
| 87 125 | 
             
                args[:move] = move.to_s
         | 
| 88 126 | 
             
                delta = { :at_tick => send_actions_at, :move => args }
         | 
| 89 127 | 
             
                send_record delta
         | 
| 90 | 
            -
                delta[: | 
| 128 | 
            +
                delta[:player_id] = @engine.player_id
         | 
| 91 129 | 
             
                @engine.add_delta delta
         | 
| 92 130 | 
             
              end
         | 
| 93 131 |  | 
| 94 132 | 
             
              def send_create_npc(npc)
         | 
| 95 | 
            -
                 | 
| 133 | 
            +
                return unless online?
         | 
| 134 | 
            +
                # :on_* hooks are for our own use; we don't send them
         | 
| 135 | 
            +
                remote_npc = npc.reject {|k,v| k.to_s.start_with? 'on_'}
         | 
| 136 | 
            +
                send_record :at_tick => send_actions_at, :add_npcs => [ remote_npc ]
         | 
| 137 | 
            +
                @engine.add_delta :at_tick => send_actions_at, :add_npcs => [ npc ]
         | 
| 138 | 
            +
              end
         | 
| 139 | 
            +
             | 
| 140 | 
            +
              def send_update_entity(entity)
         | 
| 141 | 
            +
                return unless online?
         | 
| 142 | 
            +
                delta = { :update_entities => [entity], :at_tick => send_actions_at }
         | 
| 143 | 
            +
                send_record delta
         | 
| 144 | 
            +
                @engine.add_delta delta
         | 
| 145 | 
            +
              end
         | 
| 146 | 
            +
             | 
| 147 | 
            +
              def send_snap_to_grid(entity)
         | 
| 148 | 
            +
                return unless online? && entity
         | 
| 149 | 
            +
                delta = { :at_tick => send_actions_at, :snap_to_grid => entity.registry_id }
         | 
| 96 150 | 
             
                send_record delta
         | 
| 97 151 | 
             
                @engine.add_delta delta
         | 
| 98 152 | 
             
              end
         | 
| @@ -106,6 +160,7 @@ class ClientConnection | |
| 106 160 | 
             
              end
         | 
| 107 161 |  | 
| 108 162 | 
             
              def send_record(data, reliable=false)
         | 
| 163 | 
            +
                return unless online?
         | 
| 109 164 | 
             
                debug_packet('Sending', data)
         | 
| 110 165 | 
             
                @socket.send_packet(data.to_json, reliable, 0)
         | 
| 111 166 | 
             
                @socket.flush
         | 
| @@ -123,5 +178,5 @@ class ClientConnection | |
| 123 178 | 
             
              end
         | 
| 124 179 |  | 
| 125 180 | 
             
              def online?; @socket.online?; end
         | 
| 126 | 
            -
              def disconnect; @socket.disconnect(200) | 
| 181 | 
            +
              def disconnect; @socket.disconnect(200) if online?; end
         | 
| 127 182 | 
             
            end
         | 
| @@ -41,11 +41,15 @@ class ClientEngine | |
| 41 41 | 
             
                @width, @height = world[:cell_width], world[:cell_height]
         | 
| 42 42 | 
             
                highest_id = world[:highest_id]
         | 
| 43 43 | 
             
                create_initial_space(at_tick, highest_id)
         | 
| 44 | 
            +
                @preprocessed = at_tick
         | 
| 44 45 | 
             
              end
         | 
| 45 46 |  | 
| 47 | 
            +
              alias :world_established? :tick
         | 
| 48 | 
            +
             | 
| 46 49 | 
             
              def create_initial_space(at_tick, highest_id)
         | 
| 47 50 | 
             
                @earliest_tick = @tick = at_tick
         | 
| 48 | 
            -
                space = @spaces[@tick] = GameSpace.new | 
| 51 | 
            +
                space = @spaces[@tick] = GameSpace.new(@game_window).
         | 
| 52 | 
            +
                  establish_world(@world_name, @world_id, @width, @height)
         | 
| 49 53 | 
             
                space.highest_id = highest_id
         | 
| 50 54 | 
             
                space
         | 
| 51 55 | 
             
              end
         | 
| @@ -56,20 +60,16 @@ class ClientEngine | |
| 56 60 | 
             
                fail "Can't create space at #{tick}; earliest space we know about is #{@earliest_tick}" if tick < @earliest_tick
         | 
| 57 61 |  | 
| 58 62 | 
             
                last_space = space_at(tick - 1)
         | 
| 59 | 
            -
                @spaces[tick] = new_space = GameSpace.new.copy_from(last_space)
         | 
| 63 | 
            +
                @spaces[tick] = new_space = GameSpace.new(@game_window).copy_from(last_space)
         | 
| 60 64 |  | 
| 61 | 
            -
                 | 
| 62 | 
            -
                # to match the server's behavior.  An object created during tick T
         | 
| 63 | 
            -
                # does not receive its first update until T+1.
         | 
| 64 | 
            -
                apply_deltas_before_update(tick)
         | 
| 65 | 
            +
                apply_deltas(tick)
         | 
| 65 66 | 
             
                new_space.update
         | 
| 66 | 
            -
                apply_deltas_after_update(tick)
         | 
| 67 67 |  | 
| 68 68 | 
             
                new_space
         | 
| 69 69 | 
             
              end
         | 
| 70 70 |  | 
| 71 71 | 
             
              def update
         | 
| 72 | 
            -
                return unless  | 
| 72 | 
            +
                return unless world_established?
         | 
| 73 73 |  | 
| 74 74 | 
             
                # Display the frame we received from the server as-is
         | 
| 75 75 | 
             
                if @preprocessed == @tick
         | 
| @@ -102,6 +102,7 @@ class ClientEngine | |
| 102 102 |  | 
| 103 103 | 
             
              def add_delta(delta)
         | 
| 104 104 | 
             
                at_tick = delta.delete :at_tick
         | 
| 105 | 
            +
                fail "Received delta without at_tick: #{delta.inspect}" unless at_tick
         | 
| 105 106 | 
             
                if at_tick < @tick
         | 
| 106 107 | 
             
                  $stderr.puts "Received delta #{@tick - at_tick} ticks late"
         | 
| 107 108 | 
             
                  if at_tick <= @earliest_tick
         | 
| @@ -114,42 +115,37 @@ class ClientEngine | |
| 114 115 | 
             
                @deltas[at_tick] << delta
         | 
| 115 116 | 
             
              end
         | 
| 116 117 |  | 
| 117 | 
            -
              def  | 
| 118 | 
            +
              def apply_deltas(at_tick)
         | 
| 118 119 | 
             
                space = space_at(at_tick)
         | 
| 119 120 |  | 
| 120 121 | 
             
                @deltas[at_tick].each do |hash|
         | 
| 121 | 
            -
                  players = hash | 
| 122 | 
            +
                  players = hash.delete :add_players
         | 
| 122 123 | 
             
                  add_players(space, players) if players
         | 
| 123 124 |  | 
| 124 | 
            -
                  doomed = hash | 
| 125 | 
            +
                  doomed = hash.delete :delete_entities
         | 
| 125 126 | 
             
                  delete_entities(space, doomed) if doomed
         | 
| 126 127 |  | 
| 127 | 
            -
                  updated = hash | 
| 128 | 
            +
                  updated = hash.delete :update_entities
         | 
| 128 129 | 
             
                  update_entities(space, updated) if updated
         | 
| 129 130 |  | 
| 130 | 
            -
                   | 
| 131 | 
            -
                   | 
| 132 | 
            -
             | 
| 133 | 
            -
                  score_update = hash[:update_score]
         | 
| 134 | 
            -
                  update_score(space, score_update) if score_update
         | 
| 135 | 
            -
                end
         | 
| 136 | 
            -
              end
         | 
| 137 | 
            -
             | 
| 138 | 
            -
              def apply_deltas_after_update(at_tick)
         | 
| 139 | 
            -
                space = space_at(at_tick)
         | 
| 131 | 
            +
                  snap = hash.delete :snap_to_grid
         | 
| 132 | 
            +
                  space.snap_to_grid(snap.to_sym) if snap
         | 
| 140 133 |  | 
| 141 | 
            -
             | 
| 142 | 
            -
                  npcs = hash[:add_npcs]
         | 
| 134 | 
            +
                  npcs = hash.delete :add_npcs
         | 
| 143 135 | 
             
                  add_npcs(space, npcs) if npcs
         | 
| 144 | 
            -
                end
         | 
| 145 136 |  | 
| 146 | 
            -
             | 
| 147 | 
            -
             | 
| 148 | 
            -
             | 
| 137 | 
            +
                  move = hash.delete :move
         | 
| 138 | 
            +
                  player_id = hash.delete :player_id
         | 
| 139 | 
            +
                  if move
         | 
| 140 | 
            +
                    fail "No player_id sent with move #{move.inspect}" unless player_id
         | 
| 141 | 
            +
                    apply_move(space, move, player_id.to_sym)
         | 
| 142 | 
            +
                  end
         | 
| 149 143 |  | 
| 150 | 
            -
             | 
| 151 | 
            -
             | 
| 152 | 
            -
             | 
| 144 | 
            +
                  score_update = hash.delete :update_score
         | 
| 145 | 
            +
                  update_score(space, score_update) if score_update
         | 
| 146 | 
            +
             | 
| 147 | 
            +
                  $stderr.puts "Unprocessed deltas: #{hash.keys.join(', ')}" unless hash.empty?
         | 
| 148 | 
            +
                end
         | 
| 153 149 | 
             
              end
         | 
| 154 150 |  | 
| 155 151 | 
             
              def add_player(space, hash)
         | 
| @@ -163,15 +159,18 @@ class ClientEngine | |
| 163 159 | 
             
                players.each {|json| add_player(space, json) }
         | 
| 164 160 | 
             
              end
         | 
| 165 161 |  | 
| 166 | 
            -
              def apply_move(space, move)
         | 
| 167 | 
            -
                player_id = move[:player_id]
         | 
| 162 | 
            +
              def apply_move(space, move, player_id)
         | 
| 168 163 | 
             
                player = space[player_id]
         | 
| 169 164 | 
             
                fail "No such player #{player_id}, can't apply #{move.inspect}" unless player
         | 
| 170 165 | 
             
                player.add_move move
         | 
| 171 166 | 
             
              end
         | 
| 172 167 |  | 
| 173 168 | 
             
              def add_npcs(space, npcs)
         | 
| 174 | 
            -
                npcs.each  | 
| 169 | 
            +
                npcs.each do |json|
         | 
| 170 | 
            +
                  on_create = json.delete :on_create
         | 
| 171 | 
            +
                  space << (entity = Serializable.from_json(json))
         | 
| 172 | 
            +
                  on_create.call(entity) if on_create
         | 
| 173 | 
            +
                end
         | 
| 175 174 | 
             
              end
         | 
| 176 175 |  | 
| 177 176 | 
             
              def add_entity(space, json)
         | 
| @@ -188,6 +187,7 @@ class ClientEngine | |
| 188 187 |  | 
| 189 188 | 
             
                  if my_obj = space[registry_id]
         | 
| 190 189 | 
             
                    my_obj.update_from_json(json)
         | 
| 190 | 
            +
                    my_obj.grab!
         | 
| 191 191 | 
             
                  else
         | 
| 192 192 | 
             
                    add_entity(space, json)
         | 
| 193 193 | 
             
                  end
         | 
| @@ -215,6 +215,7 @@ class ClientEngine | |
| 215 215 | 
             
              # Discard anything we think we know, in favor of the registry
         | 
| 216 216 | 
             
              # we just got from the server
         | 
| 217 217 | 
             
              def sync_registry(server_registry, highest_id, at_tick)
         | 
| 218 | 
            +
                return unless world_established?
         | 
| 218 219 | 
             
                @spaces.clear
         | 
| 219 220 | 
             
                # Any older deltas are now irrelevant
         | 
| 220 221 | 
             
                @earliest_tick.upto(at_tick - 1) {|old_tick| @deltas.delete old_tick}
         | 
    
        data/lib/game_2d/complex_move.rb
    CHANGED
    
    
| @@ -0,0 +1,35 @@ | |
| 1 | 
            +
            require 'openssl'
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Encryption
         | 
| 4 | 
            +
             | 
| 5 | 
            +
              def key=(key)
         | 
| 6 | 
            +
                @symmetric_key = key
         | 
| 7 | 
            +
              end
         | 
| 8 | 
            +
             | 
| 9 | 
            +
              def make_cipher
         | 
| 10 | 
            +
                OpenSSL::Cipher::AES.new(128, :CBC)
         | 
| 11 | 
            +
              end
         | 
| 12 | 
            +
             | 
| 13 | 
            +
              # Returns [encrypted, iv]
         | 
| 14 | 
            +
              def encrypt(data)
         | 
| 15 | 
            +
                cipher = make_cipher.encrypt
         | 
| 16 | 
            +
                cipher.key = @symmetric_key
         | 
| 17 | 
            +
                iv = cipher.random_iv
         | 
| 18 | 
            +
                [cipher.update(data) + cipher.final, iv]
         | 
| 19 | 
            +
              end
         | 
| 20 | 
            +
             | 
| 21 | 
            +
              def decrypt(data, iv)
         | 
| 22 | 
            +
                decipher = make_cipher.decrypt
         | 
| 23 | 
            +
                decipher.key = @symmetric_key
         | 
| 24 | 
            +
                decipher.iv = iv
         | 
| 25 | 
            +
                decipher.update(data) + decipher.final
         | 
| 26 | 
            +
              end
         | 
| 27 | 
            +
             | 
| 28 | 
            +
              # Same sort of thing /etc/passwd uses.
         | 
| 29 | 
            +
              # Provides no security against snooping passwords off
         | 
| 30 | 
            +
              # the wire, but does make it safer to handle the user
         | 
| 31 | 
            +
              # password file.
         | 
| 32 | 
            +
              def make_password_hash(password)
         | 
| 33 | 
            +
                password.crypt('RB')
         | 
| 34 | 
            +
              end
         | 
| 35 | 
            +
            end
         | 
    
        data/lib/game_2d/entity.rb
    CHANGED
    
    | @@ -3,6 +3,7 @@ require 'facets/kernel/constant' | |
| 3 3 | 
             
            require 'game_2d/registerable'
         | 
| 4 4 | 
             
            require 'game_2d/serializable'
         | 
| 5 5 | 
             
            require 'game_2d/entity_constants'
         | 
| 6 | 
            +
            require 'game_2d/transparency'
         | 
| 6 7 |  | 
| 7 8 | 
             
            class NilClass
         | 
| 8 9 | 
             
              # Ignore this
         | 
| @@ -10,9 +11,35 @@ class NilClass | |
| 10 11 | 
             
            end
         | 
| 11 12 |  | 
| 12 13 | 
             
            class Entity
         | 
| 14 | 
            +
              include EntityConstants
         | 
| 15 | 
            +
             | 
| 16 | 
            +
              module ClassMethods
         | 
| 17 | 
            +
                include EntityConstants
         | 
| 18 | 
            +
                # Left-most cell X position occupied
         | 
| 19 | 
            +
                def left_cell_x_at(x); x / WIDTH; end
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                # Right-most cell X position occupied
         | 
| 22 | 
            +
                # If we're exactly within a column (@x is an exact multiple of WIDTH),
         | 
| 23 | 
            +
                # then this equals left_cell_x.  Otherwise, it's one higher
         | 
| 24 | 
            +
                def right_cell_x_at(x); (x + WIDTH - 1) / WIDTH; end
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                # Top-most cell Y position occupied
         | 
| 27 | 
            +
                def top_cell_y_at(y); y / HEIGHT; end
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                # Bottom-most cell Y position occupied
         | 
| 30 | 
            +
                def bottom_cell_y_at(y); (y + HEIGHT - 1) / HEIGHT; end
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                # Velocity is constrained to the range -MAX_VELOCITY .. MAX_VELOCITY
         | 
| 33 | 
            +
                def constrain_velocity(vel)
         | 
| 34 | 
            +
                  [[vel, MAX_VELOCITY].min, -MAX_VELOCITY].max
         | 
| 35 | 
            +
                end
         | 
| 36 | 
            +
              end
         | 
| 37 | 
            +
              include ClassMethods
         | 
| 38 | 
            +
              extend ClassMethods
         | 
| 39 | 
            +
             | 
| 13 40 | 
             
              include Serializable
         | 
| 14 41 | 
             
              include Registerable
         | 
| 15 | 
            -
              include  | 
| 42 | 
            +
              include Transparency
         | 
| 16 43 |  | 
| 17 44 | 
             
              # X and Y position of the top-left corner
         | 
| 18 45 | 
             
              attr_accessor :x, :y, :moving, :space
         | 
| @@ -27,16 +54,16 @@ class Entity | |
| 27 54 | 
             
                @x, @y, self.a = x, y, a
         | 
| 28 55 | 
             
                self.x_vel, self.y_vel = x_vel, y_vel
         | 
| 29 56 | 
             
                @moving = true
         | 
| 57 | 
            +
                @grabbed = false
         | 
| 30 58 | 
             
              end
         | 
| 31 59 |  | 
| 32 60 | 
             
              def a=(angle); @a = angle % 360; end
         | 
| 33 61 |  | 
| 34 | 
            -
              # Velocity is constrained to the range -MAX_VELOCITY .. MAX_VELOCITY
         | 
| 35 62 | 
             
              def x_vel=(xv)
         | 
| 36 | 
            -
                @x_vel =  | 
| 63 | 
            +
                @x_vel = constrain_velocity xv
         | 
| 37 64 | 
             
              end
         | 
| 38 65 | 
             
              def y_vel=(yv)
         | 
| 39 | 
            -
                @y_vel =  | 
| 66 | 
            +
                @y_vel = constrain_velocity yv
         | 
| 40 67 | 
             
              end
         | 
| 41 68 |  | 
| 42 69 | 
             
              # True if we need to update this entity
         | 
| @@ -60,6 +87,12 @@ class Entity | |
| 60 87 | 
             
                @moving = true
         | 
| 61 88 | 
             
              end
         | 
| 62 89 |  | 
| 90 | 
            +
              # Entity is under direct control by a player
         | 
| 91 | 
            +
              # This is transitory state (not persisted or copied)
         | 
| 92 | 
            +
              def grab!; @grabbed = true; end
         | 
| 93 | 
            +
              def release!; @grabbed = false; end
         | 
| 94 | 
            +
              def grabbed?; @grabbed; end
         | 
| 95 | 
            +
             | 
| 63 96 | 
             
              # Give this entity a chance to perform clean-up upon destruction
         | 
| 64 97 | 
             
              def destroy!; end
         | 
| 65 98 |  | 
| @@ -68,24 +101,10 @@ class Entity | |
| 68 101 | 
             
              def pixel_x; @x / PIXEL_WIDTH; end
         | 
| 69 102 | 
             
              def pixel_y; @y / PIXEL_WIDTH; end
         | 
| 70 103 |  | 
| 71 | 
            -
               | 
| 72 | 
            -
              def  | 
| 73 | 
            -
               | 
| 74 | 
            -
              def  | 
| 75 | 
            -
             | 
| 76 | 
            -
              # Right-most cell X position occupied
         | 
| 77 | 
            -
              # If we're exactly within a column (@x is an exact multiple of WIDTH),
         | 
| 78 | 
            -
              # then this equals left_cell_x.  Otherwise, it's one higher
         | 
| 79 | 
            -
              def self.right_cell_x_at(x); (x + WIDTH - 1) / WIDTH; end
         | 
| 80 | 
            -
              def right_cell_x(x = @x); self.class.right_cell_x_at(x); end
         | 
| 81 | 
            -
             | 
| 82 | 
            -
              # Top-most cell Y position occupied
         | 
| 83 | 
            -
              def self.top_cell_y_at(y); y / HEIGHT; end
         | 
| 84 | 
            -
              def top_cell_y(y = @y); self.class.top_cell_y_at(y); end
         | 
| 85 | 
            -
             | 
| 86 | 
            -
              # Bottom-most cell Y position occupied
         | 
| 87 | 
            -
              def self.bottom_cell_y_at(y); (y + HEIGHT - 1) / HEIGHT; end
         | 
| 88 | 
            -
              def bottom_cell_y(y = @y); self.class.bottom_cell_y_at(y); end
         | 
| 104 | 
            +
              def left_cell_x(x = @x); left_cell_x_at(x); end
         | 
| 105 | 
            +
              def right_cell_x(x = @x); right_cell_x_at(x); end
         | 
| 106 | 
            +
              def top_cell_y(y = @y); top_cell_y_at(y); end
         | 
| 107 | 
            +
              def bottom_cell_y(y = @y); bottom_cell_y_at(y); end
         | 
| 89 108 |  | 
| 90 109 | 
             
              # Returns an array of one, two, or four cell-coordinate tuples
         | 
| 91 110 | 
             
              # E.g. [[4, 5], [4, 6], [5, 5], [5, 6]]
         | 
| @@ -101,13 +120,8 @@ class Entity | |
| 101 120 | 
             
                self.y_vel = @y_vel + y_accel
         | 
| 102 121 | 
             
              end
         | 
| 103 122 |  | 
| 104 | 
            -
              # Override to make particular entities transparent to each other
         | 
| 105 | 
            -
              def transparent_to_me?(other)
         | 
| 106 | 
            -
                other == self
         | 
| 107 | 
            -
              end
         | 
| 108 | 
            -
             | 
| 109 123 | 
             
              def opaque(others)
         | 
| 110 | 
            -
                others.delete_if {|obj|  | 
| 124 | 
            +
                others.delete_if {|obj| obj.equal?(self) || transparent?(self, obj)}
         | 
| 111 125 | 
             
              end
         | 
| 112 126 |  | 
| 113 127 | 
             
              # Wrapper around @space.entities_overlapping
         | 
| @@ -193,10 +207,11 @@ class Entity | |
| 193 207 | 
             
              end
         | 
| 194 208 |  | 
| 195 209 | 
             
              # Update position/velocity/angle data, and tell the space about it
         | 
| 196 | 
            -
              def warp(x, y, x_vel, y_vel, angle= | 
| 210 | 
            +
              def warp(x, y, x_vel=nil, y_vel=nil, angle=nil, moving=nil)
         | 
| 197 211 | 
             
                blk = proc do
         | 
| 198 212 | 
             
                  @x, @y, self.x_vel, self.y_vel, self.a, @moving =
         | 
| 199 | 
            -
                    x, y, x_vel, y_vel, angle, | 
| 213 | 
            +
                    (x || @x), (y || @y), (x_vel || @x_vel), (y_vel || @y_vel), (angle || @a),
         | 
| 214 | 
            +
                    (moving.nil? ? @moving : moving)
         | 
| 200 215 | 
             
                end
         | 
| 201 216 | 
             
                if @space
         | 
| 202 217 | 
             
                  @space.process_moving_entity(self, &blk)
         | 
| @@ -354,12 +369,13 @@ class Entity | |
| 354 369 | 
             
                img.draw_rot(
         | 
| 355 370 | 
             
                  self.pixel_x + CELL_WIDTH_IN_PIXELS / 2,
         | 
| 356 371 | 
             
                  self.pixel_y + CELL_WIDTH_IN_PIXELS / 2,
         | 
| 357 | 
            -
                  draw_zorder,  | 
| 372 | 
            +
                  draw_zorder, draw_angle)
         | 
| 358 373 | 
             
                # 0.5, 0.5, # rotate around the center
         | 
| 359 374 | 
             
                # 1, 1, # scaling factor
         | 
| 360 375 | 
             
                # @color, # modify color
         | 
| 361 376 | 
             
                # :add) # draw additively
         | 
| 362 377 | 
             
              end
         | 
| 378 | 
            +
              def draw_angle; a; end
         | 
| 363 379 |  | 
| 364 380 | 
             
              def to_s
         | 
| 365 381 | 
             
                "#{self.class} (#{registry_id_safe}) at #{x}x#{y}"
         |