contrib/tinks: add the client
[nit.git] / contrib / tinks / src / client / client.nit
1 # This file is part of NIT ( http://www.nitlanguage.org ).
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 # Portable client
16 module client
17
18 import mnit
19 import mnit::opengles1
20 import performance_analysis
21
22 import game
23 import common
24
25 import assets
26 import context
27
28 # A position within the screen
29 class ScreenPos
30 super Point[Float]
31
32 # Convert to a game logic `Pos` by applying camera transformation
33 fun to_logic(camera: Camera): Pos do
34 return new Pos(x/camera.basic_zoom + camera.dx, y/camera.basic_zoom + camera.dy)
35 end
36 end
37
38 redef class Pos
39 # Convert to a `ScreenPos` by applying camera transformation
40 fun to_screen(camera: Camera): ScreenPos do
41 return new ScreenPos((x - camera.dx) * camera.basic_zoom, (y - camera.dy) * camera.basic_zoom)
42 end
43 end
44
45 # Camera managing the screen view on the world
46 class Camera
47 # Offset of the top left corner of the screen, X part
48 var dx = 0.0
49
50 # Offset of the top left corner of the screen, Y part
51 var dy = 0.0
52
53 # Basic zoom, the distance between 2 features
54 #
55 # In the world logic, the distance is of 1.
56 # This value depends on the size of the graphical assets.
57 #
58 # TODO make it a full zoom by scaling images too, if needed.
59 var basic_zoom = 32.0
60
61 # Center of the `display` as world `Pos`
62 fun center(display: Display): Pos
63 do
64 return (new ScreenPos(display.width.to_f * 0.5, display.height.to_f * 0.5)).to_logic(self)
65 end
66
67 # Center the `display` on the world `pos`
68 fun center_on(display: Display, pos: Pos)
69 do
70 self.dx = pos.x - display.width.to_f * 0.5 / basic_zoom
71 self.dy = pos.y - display.height.to_f * 0.5 / basic_zoom
72 end
73 end
74
75 redef class App
76
77 # Collection of assets
78 var assets = new Assets is lazy
79
80 redef fun on_create
81 do
82 super
83 maximum_fps = 60
84 assets.assign_images_to_story context.game.story
85 end
86
87 # Camera managing transformation between world and screen positions
88 var camera = new Camera
89
90 # Context of the game, either local or remote
91 var context: GameContext is lazy do
92
93 # Server info
94 var address = null
95 var port = default_listening_port
96
97 if args.not_empty then
98 # Use first argument as the server address
99 address = args[0]
100 if args.length > 1 then port = args[1].to_i
101 else
102 print "Looking for a server..."
103
104 var s = new UDPSocket
105 s.enable_broadcast = true
106 s.blocking = false
107 s.broadcast(discovery_port, "Server? {handshake_app_name}")
108 nanosleep(0, 100_000_000)
109
110 var ptr = new Ref[nullable SocketAddress](null)
111 var resp = s.recv_from(1024, ptr)
112 var src = ptr.item
113
114 if not resp.is_empty then
115 var words = resp.split(" ")
116 if words.length == 3 and words[0] == "Server!" and words[1] == handshake_app_name and words[2].is_numeric then
117 address = src.address
118 port = words[2].to_i
119 end
120 end
121 end
122
123 if address == null then
124 print "Launching a local server"
125
126 # No command line
127 return new LocalServerContext
128 else
129 print "Connecting to:{address}:{port}"
130 maximum_fps = 0
131
132 # Args are: tinks server_address {port}
133 #var address = "riph" # args[0]
134 #var port = sys.default_listening_port
135 if args.length > 1 then port = args[1].to_i
136
137 # Setup connection config
138 var server_config = new RemoteServerConfig(address, port)
139 var server = new RemoteServer(server_config)
140
141 # Connect then complete handshake
142 assert server.connect else print_error "Connection to server failed with {server.socket.last_error or else "none"}"
143 assert server.handshake else print_error "Handshake with server failed"
144
145 # Download and setup remote game
146 var context = new RemoteGameContext(server)
147 context.setup
148
149 return context
150 end
151 end
152
153 # `Tank` of the local player, if any
154 fun local_tank: nullable Tank
155 do
156 # FIXME use a ? to one line this
157 var local_player = context.local_player
158 if local_player == null then return null
159 return local_player.tank
160 end
161
162 # Square of the minimum distance from the tank for an object to be "far"
163 #
164 # This value influences which sounds are heard,
165 # the strength of vibrations and
166 # whether an arrow points to a far unit
167 private var far_dist2 = 2000.0
168
169 # Tank tracks tracks on the ground
170 #
171 # TODO use particles or at least optimize drawing
172 var tracks = new List[Couple[Pos, Float]]
173
174 redef fun frame_core(display)
175 do
176 var clock = new Clock
177
178 var turn = context.do_turn
179 sys.perfs["do_turn"].add clock.lapse
180
181 # Draw
182
183 # Update camera
184 if down_keys.has("left") then camera.dx -= 1.0
185 if down_keys.has("right") then camera.dx += 1.0
186 if down_keys.has("up") then camera.dy -= 1.0
187 if down_keys.has("down") then camera.dy += 1.0
188
189 var local_tank = local_tank
190 if local_tank != null then
191 var tank_speed = local_tank.direction_forwards*local_tank.rule.max_speed
192 tank_speed = tank_speed.min(0.5).max(-0.5)
193
194 var prop_pos = local_tank.pos + local_tank.heading.to_vector(tank_speed * 16.0)
195 var old_pos = camera.center(display)
196 var half = old_pos.lerp(prop_pos, 0.02)
197
198 camera.center_on(display, new Pos(half.x, half.y))
199 end
200
201 # Grass
202 display.clear(0.0, 0.45, 0.0)
203
204 # Past tank tracks
205 for track in tracks do
206 var pos = track.first.to_screen(camera)
207 display.blit_rotated(assets.drawing.track, pos.x, pos.y, track.second)
208 end
209
210 # Past blast sites
211 for blast in context.game.world.blast_sites do
212 var pos = blast.to_screen(camera)
213 display.blit_centered(assets.drawing.blast, pos.x, pos.y)
214 end
215
216 # Terrain features
217 var tl = (new ScreenPos(0.0, 0.0)).to_logic(camera)
218 var br = (new ScreenPos(display.width.to_f, display.height.to_f)).to_logic(camera)
219 for x in [tl.x.floor.to_i .. br.x.ceil.to_i] do
220 for y in [tl.y.floor.to_i .. br.y.ceil.to_i] do
221 var feature = context.game.world[x, y]
222 if feature != null then
223 var pos = feature.pos.to_screen(camera)
224 var image = feature.rule.images[feature.image_index]
225 display.blit_rotated(image, pos.x, pos.y, feature.angle)
226 end
227 end
228 end
229
230 # Tanks
231 for tank in context.game.tanks do
232 # Add random tracks
233 if (tank.direction_heading != 0.0 and 40.rand == 0) or
234 (tank.direction_forwards != 0.0 and 100.rand == 0) then
235
236 tracks.add new Couple[Pos, Float](tank.pos, tank.heading)
237 if tracks.length > 1000 then tracks.shift
238 end
239
240 # Get the player stencil
241 var player = tank.player
242 var stencil = null
243 if player != null then stencil = assets.drawing.stencils[player.stencil_index]
244
245 if camera.center(display).dist2(tank.pos) > far_dist2 then
246 var hw = (display.width/2).to_f
247 var hh = (display.height/2).to_f
248
249 var angle = camera.center(display).atan2(tank.pos)
250 var x = hw + angle.cos * (hw-128.0)
251 var y = hh + angle.sin * (hh-128.0)
252
253 var screen_pos = new ScreenPos(x, y)
254 display.blit_rotated(assets.drawing.arrow, screen_pos.x, screen_pos.y, angle)
255 if stencil != null then display.blit_rotated(stencil, screen_pos.x, screen_pos.y, angle)
256 continue
257 end
258
259 var screen_pos = tank.pos.to_screen(camera)
260
261 var damage = tank.rule.max_health - tank.health
262 damage = damage.max(0).min(tank.rule.base_images.length)
263
264 var base_image = tank.rule.base_images[damage]
265 display.blit_rotated(base_image, screen_pos.x, screen_pos.y, tank.heading)
266 if stencil != null then display.blit_rotated(stencil, screen_pos.x, screen_pos.y, tank.heading)
267 display.blit_rotated(tank.rule.turret_image, screen_pos.x, screen_pos.y, tank.turret.heading)
268
269 if debug then
270 var corners = tank.corners_at(new Couple[Pos, Float](tank.pos, tank.heading))
271 for c in corners do
272 var p = c.to_screen(camera)
273 display.blit_centered(assets.drawing.red_dot, p.x, p.y)
274 end
275 end
276 end
277
278 # Events
279 for event in turn.events do
280 event.client_react(display, turn)
281
282 if event isa ExplosionEvent then
283 var pos = event.pos.to_screen(camera)
284 display.blit_centered(assets.drawing.explosion, pos.x, pos.y)
285 else if event isa OpenFireEvent then
286 var tank = event.tank
287 var screen_pos = tank.pos.to_screen(camera)
288 display.blit_rotated(assets.drawing.turret_firing, screen_pos.x, screen_pos.y, tank.turret.heading)
289
290 if tank.pos.dist2(camera.center(display)) < far_dist2 then
291 assets.turret_fire.play
292 end
293 else if event isa TurretReadyEvent then
294 if event.tank.pos.dist2(camera.center(display)) < far_dist2 then
295 assets.turret_ready.play
296 end
297 end
298 end
299
300 # Gather and show some performance stats!
301 sys.perfs["draw"].add clock.lapse
302 if context.game.tick % 300 == 5 then print sys.perfs
303 end
304
305 # Keys currently down
306 #
307 # TODO find a nice API and move up to mnit/gamnit
308 var down_keys = new HashSet[String]
309
310 redef fun input(ie)
311 do
312 var local_tank = local_tank
313 var local_player = context.local_player
314
315 # Quit?
316 if ie isa QuitEvent or
317 (ie isa KeyEvent and ie.name == "escape") then
318
319 quit = true
320 return true
321 end
322
323 # Spawn a new tank?
324 if local_tank == null and local_player != null then
325 if (ie isa KeyEvent and ie.name == "space") or
326 (ie isa PointerEvent and ie.depressed) then
327
328 local_player.orders.add new SpawnTankOrder(local_player)
329 return true
330 end
331 end
332
333 if ie isa KeyEvent then
334
335 # Update `down_keys`
336 var name = ie.name
337 if ie.is_down then
338 down_keys.add name
339 else if down_keys.has(name) then
340 down_keys.remove name
341 end
342
343 # wasd to move tank
344 var direction_change = ["w", "a", "s", "d"].has(ie.name)
345 if direction_change and local_tank != null and local_player != null then
346 var forward = down_keys.has("w")
347 var backward = down_keys.has("s")
348 var left = down_keys.has("a")
349 var right = down_keys.has("d")
350
351 # Cancel contradictory commands
352 if forward and backward then
353 forward = false
354 backward = false
355 end
356
357 if left and right then
358 left = false
359 right = false
360 end
361
362 # Set movement and direction
363 var move = 0.0
364 if forward then
365 move = 0.5
366 else if backward then move = -0.5
367
368 var ori = 0.0
369 if left then
370 ori = -local_tank.rule.max_direction/2.0
371 else if right then ori = local_tank.rule.max_direction/2.0
372
373 # Activate to invert the orientation on reverse, (for at @R4p4Ss)
374 #if backward then ori = -ori
375
376 # Bonus when only moving or only turning
377 if not forward and not backward then ori *= 2.0
378 if not left and not right then move *= 2.0
379
380 # Give order
381 local_player.orders.add new TankDirectionOrder(local_tank, ori, move)
382 return true
383 end
384 end
385
386 # On click (or tap), aim and fire
387 if ie isa PointerEvent then
388
389 if ie.pressed and local_tank != null and local_player != null then
390 var target = (new ScreenPos(ie.x, ie.y)).to_logic(camera)
391 local_player.orders.add new AimAndFireOrder(local_tank, target)
392 return true
393 end
394 end
395
396 return false
397 end
398 end
399
400 redef class TEvent
401 fun client_react(display: Display, turn: TTurn) do end
402 end
403
404 redef class ExplosionEvent
405 redef fun client_react(display, turn)
406 do
407 var pos = pos.to_screen(app.camera)
408 display.blit_centered(app.assets.drawing.explosion, pos.x, pos.y)
409 end
410 end
411
412 redef class OpenFireEvent
413 redef fun client_react(display, turn)
414 do
415 var screen_pos = tank.pos.to_screen(app.camera)
416 display.blit_rotated(app.assets.drawing.turret_firing, screen_pos.x, screen_pos.y, tank.turret.heading)
417
418 if tank.pos.dist2(app.camera.center(display)) < app.far_dist2 then
419 # Within earshot
420 app.assets.turret_fire.play
421 end
422 end
423 end
424
425 redef class TurretReadyEvent
426 redef fun client_react(display, turn)
427 do
428 if tank.pos.dist2(app.camera.center(display)) < app.far_dist2 then
429 # Within earshot
430 app.assets.turret_ready.play
431 end
432 end
433 end