Tweak logic around restarting, to avoid some bugs
[erdslangetjie.git] / erdslangetjie / __main__.py
1 from erdslangetjie.constants import TILE_SIZE, LEFT, RIGHT, UP, DOWN
2
3 from kivy.app import App
4 from kivy.uix.widget import Widget
5 from kivy.uix.relativelayout import RelativeLayout
6 from kivy.uix.scrollview import ScrollView
7 from kivy.uix.label import Label
8 from kivy.uix.popup import Popup
9 from kivy.graphics import Color, Rectangle
10 from kivy.utils import platform
11 from kivy.clock import Clock
12 from kivy.config import Config
13
14 from erdslangetjie.level import LevelList
15 from erdslangetjie.data import load_image, load_sound
16 from erdslangetjie.player import ThePlayer, Nemesis
17
18
19 if platform() != 'android':
20     Config.set('graphics', 'width', '1026')
21     Config.set('graphics', 'height', '760')
22
23
24 class GameWindow(RelativeLayout):
25
26     def __init__(self, level_list, app):
27         self.level_list = level_list
28         self.level_obj = self.level_list.get_current_level()
29         self.level_obj.load_tiles()
30         self.tiles = {}
31         self.app = app
32
33         cols, rows = self.level_obj.get_size()
34
35         super(GameWindow, self).__init__(
36                 size=(cols * TILE_SIZE, rows * TILE_SIZE),
37                 size_hint=(None, None))
38
39         self.mouse_move = False
40
41         self.caught = load_sound('sounds/caught.ogg')
42
43         self.player = ThePlayer()
44         self.nemesis = Nemesis(self.app.config)
45         if not self.level_obj.enter_pos:
46             raise RuntimeError('No entry point')
47         self.player_tile = None
48         self.nemesis_tile = None
49         self.timer_set = False
50         self.move_counter = 0
51
52         self.player.pos = self.level_obj.enter_pos
53         self.keyboard = None
54         self._key_bound = False
55
56     def build(self):
57         if platform() != 'android' and not self.keyboard:
58             # Very hack'ish
59             # We need to delay this import until after the window creation by
60             # the app, else our size config doesn't work
61             from kivy.core.window import Window
62             self.keyboard = Window.request_keyboard(self._closed, self)
63         if self.keyboard and not self._key_bound:
64             # We remove this binding when we're the not top level widget,
65             # so re-add it here
66             self._key_bound = True
67             self.keyboard.bind(on_key_down=self._on_key_down)
68         self.clear_widgets()
69         self.tiles = {}
70         tiles = self.level_obj.get_tiles()
71         bx, by = 0, 0
72         for tile_line in tiles:
73             bx = 0
74             for tile in tile_line:
75                 self.draw_tile((bx, by), tile)
76                 bx += TILE_SIZE
77             by += TILE_SIZE
78
79     def draw_tile(self, pos, tile):
80         if pos in self.tiles:
81             self.remove_widget(self.tiles[pos])
82         node = Widget(size=(TILE_SIZE, TILE_SIZE),
83                 pos=pos, size_hint=(None, None))
84         self.add_widget(node)
85         with node.canvas:
86             Color(1, 1, 1)
87             Rectangle(pos=node.pos, size=node.size,
88                     texture=tile.texture)
89         self.tiles[pos] = node
90
91     def fix_scroll_margins(self):
92         # We need to call this after app.root is set
93         self.view = self.app.root
94         self.x_scroll_margin = float(TILE_SIZE) / self.view.size[0]
95         self.y_scroll_margin = float(TILE_SIZE) / self.view.size[1]
96
97     def draw_player(self):
98         if self.player_tile:
99             self.remove_widget(self.player_tile)
100         sprite_pos = (self.player.pos[0] * TILE_SIZE,
101                 self.player.pos[1] * TILE_SIZE)
102         self.player_tile = Widget(size=(TILE_SIZE, TILE_SIZE),
103                 pos=sprite_pos)
104         with self.player_tile.canvas:
105             Color(1, 1, 1)
106             Rectangle(pos=sprite_pos, size=self.player_tile.size,
107                     texture=self.player.get_texture())
108         self.add_widget(self.player_tile)
109         for offset in [(TILE_SIZE - 1, TILE_SIZE - 1),
110                 (-TILE_SIZE + 1, TILE_SIZE - 1),
111                 (TILE_SIZE - 1, -TILE_SIZE + 1),
112                 (-TILE_SIZE + 1, -TILE_SIZE + 1),
113                 (0, 2 * TILE_SIZE - 2),
114                 (-2 * TILE_SIZE + 2, 0),
115                 (2 * TILE_SIZE - 2, 0),
116                 (0, -2 * TILE_SIZE + 2),
117                 (0, 0)]:
118             # Aim is to ensure a 'neighbourhood' around the player
119             # is visible if possible
120             check_point = (sprite_pos[0] + offset[0] + TILE_SIZE / 2,
121                     sprite_pos[1] + offset[1] + TILE_SIZE / 2)
122             true_point = self.to_parent(*check_point)
123             if check_point[0] < 0:
124                 continue
125             if check_point[1] < 0:
126                 continue
127             if check_point[0] >= self.size[0]:
128                 continue
129             if check_point[1] >= self.size[1]:
130                 continue
131             while not self.included(true_point, 0):
132                 # Scroll ourselves
133                 if true_point[0] >= self.view.size[0]:
134                     self.view.scroll_x += self.x_scroll_margin
135                     true_point = self.to_parent(*check_point)
136                 elif true_point[0] < 0:
137                     self.view.scroll_x -= self.x_scroll_margin
138                     true_point = self.to_parent(*check_point)
139                 elif true_point[1] >= self.view.size[1]:
140                     self.view.scroll_y += self.y_scroll_margin
141                     true_point = self.to_parent(*check_point)
142                 elif true_point[1] < 0:
143                     self.view.scroll_y -= self.y_scroll_margin
144                     true_point = self.to_parent(*check_point)
145
146     def included(self, point, margin):
147         if point[0] < margin:
148             return False
149         if point[0] >= self.view.size[0] - margin:
150             return False
151         if point[1] < margin:
152             return False
153         if point[1] >= self.view.size[1] - margin:
154             return False
155         return True
156
157     def draw_nemesis(self):
158         if not self.nemesis.on_board():
159             return
160         if self.nemesis_tile:
161             self.remove_widget(self.nemesis_tile)
162         sprite_pos = (self.nemesis.pos[0] * TILE_SIZE,
163                 self.nemesis.pos[1] * TILE_SIZE)
164         self.nemesis_tile = Widget(size=(TILE_SIZE, TILE_SIZE),
165                 pos=sprite_pos)
166         with self.nemesis_tile.canvas:
167             Color(1, 1, 1)
168             Rectangle(pos=sprite_pos, size=self.nemesis_tile.size,
169                     texture=self.nemesis.get_texture())
170         self.add_widget(self.nemesis_tile)
171
172     def _closed(self):
173         if self.keyboard:
174             self._key_bound = False
175             self.keyboard.unbind(on_key_down=self._on_key_down)
176
177     def _on_key_down(self, keyboard, keycode, text, modifiers):
178         direction = None
179         letter = keycode[1].lower()
180         if letter in UP:
181             direction = (0, 1)
182         elif letter in DOWN:
183             direction = (0, -1)
184         elif letter in LEFT:
185             direction = (-1, 0)
186         elif letter in RIGHT:
187             direction = (1, 0)
188         if direction:
189             self.do_move(direction)
190
191     def do_move(self, direction):
192         if not self.level_obj:
193             return
194         # Do nothing on null moves
195         if not self.player.move(direction, self.level_obj):
196             return
197         self.check_state()
198         self.do_nemesis_move()
199
200     def do_nemesis_move(self):
201         self.nemesis.move(self.level_obj, self.player.pos)
202         self.check_state()
203         if self.move_counter > 4:
204             self.move_counter = 0
205             self.draw_nemesis()
206             self.nemesis.move(self.level_obj, self.player.pos)
207             self.check_state()
208         else:
209             self.move_counter += 1
210         self.draw_nemesis()
211         self.draw_player()
212         self.reset_timer()
213
214     def timed_move(self, event):
215         if not self.level_obj:
216             return
217         self.do_nemesis_move()
218
219     def reset_timer(self):
220         self.timer_set = True
221         Clock.unschedule(self.timed_move)
222         Clock.schedule_once(self.timed_move, 3)
223
224     def check_caught(self):
225         return self.nemesis.pos == self.player.pos
226
227     def stop_game(self):
228         Clock.unschedule(self.timed_move)
229         if self.nemesis_tile:
230             self.remove_widget(self.nemesis_tile)
231         self.nemesis.reset_pos()
232
233     def reset_level(self):
234         Clock.unschedule(self.timed_move)
235         self.timer_set = False
236         self.move_counter = 0
237         if self.nemesis_tile:
238             self.remove_widget(self.nemesis_tile)
239         self.nemesis.reset_pos()
240
241     def load_level(self):
242         if self.level_obj:
243             self.level_obj.load_tiles()
244             self.player.pos = self.level_obj.enter_pos
245             if self.player_tile:
246                 self.remove_widget(self.player_tile)
247             self.view.scroll_x = 0
248             self.view.scroll_y = 0
249             self.build()
250             self.draw_nemesis()
251             self.draw_player()
252             return True
253         return False
254
255     def do_reload(self):
256         self.level_obj = self.level_list.get_current_level()
257
258     def check_state(self):
259         if not self.level_obj:
260             return
261         if self.level_obj.at_exit(self.player.pos):
262             self.reset_level()
263             # Jump to next level
264             self.level_obj = self.level_list.advance_to_next_level()
265             if not self.load_level():
266                 self._closed()
267                 self.app.game_over(True)
268             return
269         elif self.check_caught():
270             # Caught
271             if self.app.config.getdefault('bane', 'sound', '0') != '0':
272                 self.caught.play()
273             self.reset_level()
274             self._closed()
275             self.app.game_over(False)
276             return
277         elif self.level_obj.is_button(self.player.pos):
278             self.level_obj.trigger_button(self.player.pos)
279         elif self.level_obj.is_button(self.nemesis.pos):
280             self.level_obj.trigger_button(self.nemesis.pos)
281         for map_pos, new_tile in self.level_obj.get_changed_tiles():
282             pos = (map_pos[0] * TILE_SIZE, map_pos[1] * TILE_SIZE)
283             self.draw_tile(pos, new_tile)
284
285     def _calc_mouse_pos(self, pos):
286         pos = self.to_local(*pos)
287         return (int(pos[0] / TILE_SIZE), int(pos[1] / TILE_SIZE))
288
289     def on_touch_down(self, touch):
290         pos = self._calc_mouse_pos(touch.pos)
291         if pos == self.player.pos:
292             self.mouse_move = True
293             self.mouse_start = pos
294
295     def on_touch_up(self, touch):
296         self.mouse_move = False
297
298     def on_touch_move(self, touch):
299         if self.mouse_move:
300             pos = self._calc_mouse_pos(touch.pos)
301             if (pos[0] - self.mouse_start[0] != 0) or (
302                     pos[1] - self.mouse_start[1] != 0):
303                 direction = (pos[0] - self.mouse_start[0],
304                         pos[1] - self.mouse_start[1])
305                 self.do_move(direction)
306                 self.mouse_start = pos
307
308
309 class Screen(Widget):
310
311     BACKGROUND = None
312     START = 'Start'
313
314     def __init__(self, app):
315         super(Screen, self).__init__()
316         self.image = load_image(self.BACKGROUND)
317         self.app = app
318         with self.canvas:
319             Rectangle(pos=(0, 0), size=(1026, 760),
320                     texture=self.image.texture)
321
322         self.stop_button = Label(
323                 text='[ref=quit][color=ff0066]Quit[/color][/ref]',
324                 font_size=30,
325                 markup=True,
326                 size=(200, 40),
327                 pos=((1026 - 200) / 2 - 100, 100))
328         self.stop_button.bind(on_ref_press=self.app.stop_app)
329         self.start_button = Label(
330                 text="[ref=start][color=00ff66]%s[/color][/ref]" % self.START,
331                 font_size=30,
332                 markup=True, size=(200, 40),
333                 pos=((1026 - 200) / 2 + 100, 100))
334         self.start_button.bind(on_ref_press=self.app.start_game)
335         self.add_widget(self.stop_button)
336         self.add_widget(self.start_button)
337
338
339 class IntroScreen(Screen):
340
341     BACKGROUND = 'screens/intro_screen.png'
342     START = 'Start the Game'
343
344
345 class WonScreen(Screen):
346
347     BACKGROUND = 'screens/won.png'
348     START = 'Play again?'
349
350
351 class LostScreen(Screen):
352
353     BACKGROUND = 'screens/lost.png'
354     START = 'Retry?'
355
356
357 class GameApp(App):
358
359     title = "Bane's Befuddlement"
360
361     def __init__(self):
362         super(GameApp, self).__init__()
363         self.levels = LevelList()
364         self.game = None
365
366     def build_config(self, config):
367         config.setdefaults('bane', {
368             'start_level': 'levels/level1.txt',
369             'sound': 'True'
370             })
371
372     def build_settings(self, settings):
373         config_json = """[
374             { "type": "title",
375               "title": "Bane's Befuddlement"
376             },
377
378             { "type": "options",
379               "title": "Start Level",
380               "desc": "Level to start at",
381               "section": "bane",
382               "key": "start_level",
383               "options": ["%s"] },
384
385             { "type": "bool",
386               "title": "Sound",
387               "desc": "Enable sound",
388               "section": "bane",
389               "key": "sound"
390              }
391              ]""" % '", "'.join(self.levels.get_level_names())
392         settings.add_json_panel("Bane's Befuddlement",
393                 self.config, data=config_json)
394
395     def build(self):
396         root = ScrollView(size_hint=(None, None))
397         level_name = self.config.getdefault('bane', 'start_level', None)
398         if level_name:
399             self.levels.set_level_to(level_name)
400         self.game = GameWindow(self.levels, self)
401         return root
402
403     def on_start(self):
404         from kivy.base import EventLoop
405         window = EventLoop.window
406         if platform() == 'android':
407             window.fullscreen = True
408         self.root.size = window.size
409         errors = self.levels.get_errors()
410         if errors:
411             popup = Popup(title='Levels excluded',
412                     content=Label(text='\n'.join(errors)),
413                     size_hint=(.5, .5))
414             popup.open()
415         self.make_intro()
416
417     def make_intro(self):
418         self.root.clear_widgets()
419         screen = IntroScreen(self)
420         self.root.add_widget(screen)
421
422     def stop_app(self, label, ref):
423         self.stop()
424
425     def start_game(self, label, ref):
426         """Start the game"""
427         self.root.clear_widgets()
428         self.root.add_widget(self.game)
429         self.game.fix_scroll_margins()
430         self.game.reset_level()
431         self.game.load_level()
432         # Ensure the player is visible
433         self.root.scroll_x = 0
434         self.root.scroll_y = 0
435         self.game.draw_player()
436         self.game.draw_nemesis()
437
438     def game_over(self, won):
439         if won:
440             screen = WonScreen(self)
441             self.levels.reset()
442             self.game.do_reload()
443         else:
444             screen = LostScreen(self)
445         self.game.stop_game()
446         self.root.clear_widgets()
447         self.root.add_widget(screen)
448
449
450 def main():
451     """ Erdslangetjie, a maze game of eluding nemesis
452     """
453     GameApp().run()