gamnit: split use of App::on_create between create_gamnit and create_scene
[nit.git] / lib / gamnit / virtual_gamepad / virtual_gamepad.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 # Virtual gamepad mapped to keyboard keys for quick and dirty mobile support
16 #
17 # For Android, the texture is automatically added to the APK when this
18 # module is imported. However, due to a limitation of the _app.nit_
19 # framework on desktop OS, the texture must be copied manually to the assets
20 # folder at `assets/images`, the texture is available at, from the repo root,
21 # `lib/gamnit/virtual_gamepad/assets/images`.
22 #
23 # The texture was created by kenney.nl and modified by Alexis Laferrière.
24 # It is published under CC0 and can be used and modified without attribution.
25 #
26 # ~~~
27 # redef class App
28 # redef fun create_scene
29 # do
30 # super
31 #
32 # # Create the virtual gamepad
33 # var gamepad = new VirtualGamepad
34 #
35 # # Configure it as needed
36 # gamepad.add_dpad(["w","a","s","d"])
37 # gamepad.add_button("x", gamepad_spritesheet.x)
38 # gamepad.add_button("space", gamepad_spritesheet.star)
39 #
40 # # Assign it as the active gamepad
41 # self.gamepad = gamepad
42 # end
43 #
44 # fun setup_play_ui
45 # do
46 # # Show the virtual gamepad
47 # var gamepad = self.gamepad
48 # if gamepad != null then gamepad.visible = true
49 # end
50 # end
51 # ~~~
52 module virtual_gamepad is app_files
53
54 import flat
55 private import keys
56 import virtual_gamepad_spritesheet
57
58 redef class App
59
60 # Current touch gamepad, still may be invisible
61 var gamepad: nullable VirtualGamepad = null is writable
62
63 # Textures used for `DPad` and available to clients
64 var gamepad_spritesheet = new VirtualGamepadSpritesheet
65
66 redef fun accept_event(event)
67 do
68 # Priority to the gamepad
69 var gamepad = gamepad
70 if gamepad != null and gamepad.accept_event(event) then return true
71
72 return super
73 end
74 end
75
76 # Gamepad on touch screen bound to keyboard keys
77 #
78 # Fires `VirtualGamepadEvent` which implement `KeyEvent` so it behaves like a keyboard.
79 class VirtualGamepad
80
81 private var sprites = new Array[Sprite]
82
83 # Controls composing this gamepad
84 #
85 # Controls can be added directly to this array or using `add_dpad`
86 # and `add_button`.
87 var controls = new Array[RoundControl]
88
89 # Add and return a directional pad (`DPad`) to a default location
90 #
91 # The 4 buttons fire events with the corresponding name in `names`.
92 # Items in `names` should be in order of top, left, down and right.
93 # If `null`, defaults to WASD.
94 #
95 # If this method is called, it should be before `add_button` to
96 # avoid overlapping controls.
97 #
98 # A maximum of 2 `DPad` may be added using this method.
99 # The first `DPad` is placed on the left of the screen.
100 # The second `DPad` is on the right and replaces some buttons
101 # added by `add_button`.
102 #
103 # Require: `names == null or names.length == 4`
104 fun add_dpad(names: nullable Array[String]): nullable DPad
105 do
106 if names == null then names = ["w","a","s","d"]
107 assert names.length == 4
108
109 if n_dpads == 0 then
110 var dpad = new DPad(app.ui_camera.bottom_left.offset(200.0, 100.0, 0.0), names)
111 controls.add dpad
112 return dpad
113 else if n_dpads == 1 then
114 var dpad = new DPad(app.ui_camera.bottom_right.offset(-200.0, 100.0, 0.0), names)
115 controls.add dpad
116 return dpad
117 else
118 print_error "Too many DPad ({n_dpads}) in {self}"
119 return null
120 end
121 end
122
123 # Number of `DPad` in `controls`
124 private fun n_dpads: Int
125 do
126 var n_dpads = 0
127 for c in controls do if c isa DPad then n_dpads += 1
128 return n_dpads
129 end
130
131 # Button positions for `add_button`, offsets from the bottom right
132 private var button_positions = new Array[Point[Float]].with_items(
133 new Point[Float](-150.0, 150.0),
134 new Point[Float](-150.0, 350.0),
135 new Point[Float](-150.0, 550.0),
136 new Point[Float](-350.0, 150.0),
137 new Point[Float](-350.0, 350.0),
138 new Point[Float](-350.0, 550.0))
139
140 # Add and return a round button to a default location
141 #
142 # Fired events use `name`, it should usually correspond to a
143 # keyboard key like "space" or "a".
144 # `texture` is displayed at the button position, it also sets the
145 # touchable surface of the button.
146 #
147 # If this method is called, it should be after `add_dpad` to
148 # avoid overlapping controls.
149 #
150 # A maximum of 6 buttons may be added using this method when
151 # there is less than 2 `DPad`. Otherwise, only 2 buttons can be added.
152 fun add_button(name: String, texture: Texture): nullable RoundButton
153 do
154 if n_dpads == 2 and button_positions.length == 6 then
155 # Drop the bottom 4 buttons
156 button_positions.remove_at 4
157 button_positions.remove_at 3
158 button_positions.remove_at 1
159 button_positions.remove_at 0
160 end
161
162 assert button_positions.not_empty else print_error "Too many buttons in {self}"
163 var pos = button_positions.shift
164 var but = new RoundButton(
165 app.ui_camera.bottom_right.offset(pos.x, pos.y, 0.0), name, texture)
166 controls.add but
167 return but
168 end
169
170 private fun prepare
171 do
172 var display = app.display
173 assert display != null
174
175 for control in controls do
176 var sprites = control.sprites
177 app.ui_sprites.add_all sprites
178 end
179 end
180
181 # Is this control visible?
182 var visible = false is private writable(visible_direct=)
183
184 # Set this control to visible or not
185 fun visible=(value: Bool)
186 do
187 visible_direct = value
188 if value then show else hide
189 end
190
191 private fun show
192 do
193 if sprites.is_empty then prepare
194 app.ui_sprites.add_all sprites
195 end
196
197 private fun hide do for s in sprites do app.ui_sprites.remove_all s
198
199 private var control_under_pointer = new Map[Int, RoundControl]
200
201 private fun accept_event(event: InputEvent): Bool
202 do
203 if not visible then return false
204
205 var display = app.display
206 if display == null then return false
207
208 if event isa PointerEvent then
209 var ui_pos = app.ui_camera.camera_to_ui(event.x, event.y)
210
211 for control in controls do
212 if control.accept_event(event, ui_pos) then
213 var prev_control = control_under_pointer.get_or_null(event.pointer_id)
214 if prev_control != null and prev_control != control then
215 prev_control.depressed_down
216 end
217 control_under_pointer[event.pointer_id] = control
218 return true
219 end
220 end
221
222 var prev_control = control_under_pointer.get_or_null(event.pointer_id)
223 if prev_control != null then prev_control.depressed_down
224 control_under_pointer.keys.remove event.pointer_id
225 end
226
227 return false
228 end
229 end
230
231 # Event fired by a `VirtualGamepad`
232 class VirtualGamepadEvent
233 super KeyEvent
234
235 redef var name
236
237 redef var is_down is noautoinit
238 end
239
240 # Control composing a `VirtualGamepad`
241 abstract class RoundControl
242 # Center position on the UI
243 var center: Point3d[Float]
244
245 # Radius in UI units/pixels of the part of this control
246 fun radius: Float is abstract
247
248 private fun sprites: Array[Sprite] do return new Array[Sprite]
249
250 private fun accept_event(event: InputEvent, ui_pos: Point[Float]): Bool
251 do
252 if event isa PointerEvent and contains(ui_pos) then
253 return hit(event, ui_pos)
254 end
255
256 return false
257 end
258
259 # Does `self` contain a pointer at `ui_pos`?
260 private fun contains(ui_pos: Point[Float]): Bool
261 do
262 var dx = center.x - ui_pos.x
263 var dy = center.y - ui_pos.y
264 return dx*dx + dy*dy < radius*radius
265 end
266
267 private fun hit(event: PointerEvent, ui_pos: Point[Float]): Bool
268 do return false
269
270 # Keys currently down, to be depressed if the pointer moves away
271 private var down_names = new Set[String]
272
273 # Depress/release keys kept down, listed by `down_names`
274 private fun depressed_down
275 do
276 for name in down_names do
277 var e = new VirtualGamepadEvent(name)
278 e.is_down = false
279 app.accept_event e
280 end
281 down_names.clear
282 end
283 end
284
285 # Simple action button
286 class RoundButton
287 super RoundControl
288
289 # Event name, should usually correspond to a keyboard key like "a" or "left"
290 var name: String
291
292 # Texture drawn for this button, may be from `app.gamepad_spritesheet`
293 var texture: Texture
294
295 redef fun radius do return 0.5*texture.height
296
297 redef fun hit(event, ui_pos)
298 do
299 if not event.is_move then
300 var e = new VirtualGamepadEvent(name.to_s)
301 e.is_down = event.pressed
302 app.accept_event e
303
304 if event.pressed then
305 down_names.add name
306 else down_names.clear
307 end
308 return true
309 end
310
311 redef var sprites = [new Sprite(texture, center)] is lazy
312 end
313
314 # Directional pad with up to 4 buttons
315 #
316 # Assumes that each pad is manipulated by at max a single pointer.
317 class DPad
318 super RoundControl
319
320 # Event names for the keys, in order of top, left, down and right
321 var names: Array[String]
322
323 # Show the up button
324 var show_up = true is writable
325
326 # Show the down button
327 var show_down = true is writable
328
329 # Show the left button
330 var show_left = true is writable
331
332 # Show the right button
333 var show_right = true is writable
334
335 redef fun radius do return 200.0
336
337 redef fun contains(ui_pos)
338 do
339 if show_down then return super(new Point[Float](ui_pos.x+0.0, ui_pos.y-100.0))
340 return super
341 end
342
343 redef fun hit(event, ui_pos)
344 do
345 var display = app.display
346 if display == null then return false
347
348 var dx = ui_pos.x - center.x
349 var dy = ui_pos.y - center.y
350 if show_down then dy -= 100.0
351
352 # Angle (with 0.0 on the right) to index in WASD (0 -> W/up)
353 var indexes = new Set[Int]
354 var ao = atan2(dy, dx)
355 ao -= pi/4.0
356
357 # Look for 2 angles so 2 buttons can be pressed at the same time
358 for da in once [-pi/8.0, pi/8.0] do
359 var a = ao+da
360 while a < 0.0 do a += pi*2.0
361 while a > 2.0*pi do a -= pi*2.0
362 var index = (a * 2.0 / pi).floor.to_i
363 if index < 0 then index += 4
364 indexes.add index
365 end
366
367 var shows = [show_up, show_left, show_down, show_right]
368 var new_down_names = new Set[String]
369 for index in indexes do
370 # Don't trigger events for hidden buttons/directions
371 if not shows[index] then continue
372
373 var name = names[index]
374 # Simulate event
375 var e = new VirtualGamepadEvent(name)
376 e.is_down = event.pressed
377 app.accept_event e
378
379 if event.pressed then new_down_names.add name
380 end
381
382 # Depress released directions
383 for name in down_names do
384 if not new_down_names.has(name) then
385 var e = new VirtualGamepadEvent(name)
386 e.is_down = false
387 app.accept_event e
388 end
389 end
390
391 down_names = new_down_names
392
393 return true
394 end
395
396 redef fun sprites
397 do
398 var dy = 0.0
399 if show_down then dy = 100.0
400
401 var sprites = new Array[Sprite]
402
403 # Interactive buttons
404 if show_up then sprites.add new Sprite(app.gamepad_spritesheet.dpad_up,
405 center.offset( 0.0, 100.0+dy, 0.0))
406 if show_left then sprites.add new Sprite(app.gamepad_spritesheet.dpad_left,
407 center.offset(-100.0, 0.0+dy, 0.0))
408 if show_right then sprites.add new Sprite(app.gamepad_spritesheet.dpad_right,
409 center.offset( 100.0, 0.0+dy, 0.0))
410 if show_down then sprites.add new Sprite(app.gamepad_spritesheet.dpad_down,
411 center.offset( 0.0,-100.0+dy, 0.0))
412
413 # Non-interactive joystick background
414 sprites.add new Sprite(app.gamepad_spritesheet.joystick_back,
415 center.offset(0.0, 0.0+dy, -1.0)) # In the back
416 if not show_down then sprites.add new Sprite(app.gamepad_spritesheet.joystick_down,
417 center.offset(0.0, -100.0+dy, 0.0))
418
419 return sprites
420 end
421 end