game.py (24594B)
1 import pygame, os, json 2 from . import interface, images, board, entity, ui, message, dungeon, battle 3 from .gamelib import * 4 5 ########### 6 # game.py # 7 ########### 8 9 # This file contains: 10 # 1. The 'Game' object, which represents the entire game application. 11 12 ############################## 13 # Section 1 - The Game Class # 14 ############################## 15 16 class Game(object): 17 """ 18 Game is the object that represents the entire running 19 game application. Everything is a subcomponent of and 20 subordinate to the Game object, and there is only ever 21 a single instance of Game. 22 """ 23 24 def __init__(self): 25 26 # Basic values 27 self.on = True 28 29 # Mode values 30 self.state_mode = None 31 self.control_mode = None 32 33 # Pygame objects 34 self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) 35 self.frame_clock = pygame.time.Clock() 36 self.viewport = pygame.Surface((2 * (SCREEN_WIDTH // 3), 2 * (SCREEN_HEIGHT // 3))) 37 self.viewport_rect = self.viewport.get_rect() 38 self.viewport_rect.topleft = SCREEN_MARGINS 39 40 # Game components 41 self.interface = interface.Interface(self) 42 self.message_board = message.MessageBoard(self, (313, 620), (696, 72)) # TODO: Hardcoded. Need to make the whole UI resolution-aware... 43 44 # Gameplay objects 45 self.player = None 46 self.player_name = "" 47 self.party = [] 48 self.party_group = pygame.sprite.Group() 49 50 # UI elements 51 self.ui_font = pygame.font.Font(os.path.join(FONT_PATH, MESSAGE_FONT), MESSAGE_FONT_SIZE) # TODO: Should there be a UI font option??? 52 self.small_ui_font = pygame.font.Font(os.path.join(FONT_PATH, MESSAGE_FONT), MESSAGE_FONT_SIZE - 3) 53 self.tiny_ui_font = pygame.font.Font(os.path.join(FONT_PATH, MESSAGE_FONT), MESSAGE_FONT_SIZE - 7) 54 self.date_string = "" 55 self.ui_tray = None 56 self.ui_group = pygame.sprite.Group() 57 self.location_element = None 58 self.location_element_pos = (0, 0) 59 self.date_element = None 60 self.date_element_pos = (0, 0) 61 self.clock_element = None 62 self.clock_element_pos = (0, 0) 63 64 # Progress values 65 self.prog_flags = { "npc" : {}, "quest" : {}, "other" : {} } 66 67 # Other values 68 self.active_scenario = "Tzed 1" # TMP! 69 self.sheets = {} 70 self.loaded_boards = {} 71 self.loaded_dungeons = {} 72 self.current_board = None 73 self.current_dungeon = None 74 self.current_battle = None 75 self.gamedata = {} 76 self.gametime = 0 77 self.old_gametime = -1 78 self.seconds = 0 79 self.minutes = 0 80 self.hours = 0 81 self.days = 0 82 self.months = 0 83 self.years = 0 84 self.day_descriptors = ["Advent", "Apex", "Verge"] 85 self.month_names = ["Turrich's Reign", "The Rebirth", "Thrive", "Arbala's Reign", "The Long Days", "The Turning", "Mensor's Reign", "Cold Winds", "The Nights"] 86 self.datestring = "" 87 self.clockstring = "" 88 self.old_location = "" 89 90 # Loading 91 self.load_sheets("sheets.json") 92 self.load_boards("boards.json") 93 self.load_dungeons("dungeons.json") 94 self.build_ui() 95 96 # TMP! 97 self.load_game("savegame1") 98 99 def load_sheets(self, imagefile): 100 """ 101 Load all of the images as sheets. 102 """ 103 # Load the sheet definition from JSON 104 with open(os.path.join(ETC_PATH, imagefile)) as sf: 105 sheetdef = json.load(sf) 106 107 # Create the sheet objects 108 for s in sheetdef: 109 self.sheets[s] = images.Sheet(load_image(str(s) + ".png", sheetdef[s][2]), s, (sheetdef[s][0], sheetdef[s][1]), sheetdef[s][2]) 110 111 def load_boards(self, boardfile): 112 """ 113 Load boards from a file. 114 """ 115 # Load board from json 116 with open(os.path.join(ETC_PATH, boardfile)) as bf: 117 boardlist = json.load(bf) 118 119 # Create board and add it to loaded boards 120 for b in boardlist["boards"]: 121 with open(os.path.join(BOARD_PATH, b, "board.json")) as bj: 122 boarddef = json.load(bj) 123 with open(os.path.join(BOARD_PATH, b, "entities.json")) as ej: 124 entdef = json.load(ej) 125 pal = { tuple(i[0]): i[1] for i in boarddef["palette"] } 126 nb = board.Board(self, b, boarddef["display_name"], pal, (boarddef["board_width"], boarddef["board_height"]), (boarddef["cell_width"], boarddef["cell_height"]), boarddef["cells"], boarddef["scale"], entdef) 127 self.loaded_boards[b] = nb 128 129 def load_dungeons(self, dungeonfile): 130 """ 131 Load dungeons from file. 132 """ 133 # Load dungeons from json 134 with open(os.path.join(ETC_PATH, dungeonfile)) as df: 135 dungeonlist = json.load(df) 136 137 # Create dungeons and add to loaded dungeons 138 for d in dungeonlist["dungeons"]: 139 with open(os.path.join(DUNGEON_PATH, d, "dungeon.json")) as dj: 140 dungeondef = json.load(dj) 141 with open(os.path.join(DUNGEON_PATH, d, "encounters.json")) as ej: 142 encountdef = json.load(ej) 143 nd = dungeon.Dungeon(self, d, dungeondef, encountdef) 144 self.loaded_dungeons[d] = nd 145 146 def build_ui(self): 147 """ 148 Create the out-of-board game UI. 149 """ 150 # TODO: This is totally not generic. Would it be worth it 151 # to abstract this in some way? That will have to be 152 # done for menus anyway... 153 self.ui_tray = entity.CustomSprite(self.sheets["ui_tray"].sprites[(0, 0)], (0, 0)) 154 self.ui_group.add(self.ui_tray) 155 156 def switch_board(self, boardname, cellpos): 157 """ 158 Switch to a given board and cell, and 159 populate its adjacents and entities. 160 """ 161 if boardname in self.loaded_boards.keys(): 162 if self.current_board != None: 163 self.old_location = self.current_board.display_name 164 self.current_board = self.loaded_boards[boardname] 165 self.current_board.change_cell(cellpos) 166 self.collect_game_data() 167 self.message_board.post("$PLAYERNAME enters $CURRENTBOARDNAME.", self.gamedata) 168 else: 169 return 1 170 171 def switch_dungeon(self, dungeonname, enter = False, direction = 0, floor = 0, cell = (0, 0)): 172 """ 173 Switch to the given dungeon and go 174 to the given floor, facing the given 175 direction, and in the given cell. 176 """ 177 if dungeonname in self.loaded_dungeons.keys(): 178 if enter: 179 direction = self.loaded_dungeons[dungeonname].entrance_direction 180 floor = 0 181 cell = self.loaded_dungeons[dungeonname].entrance_cell 182 self.current_dungeon = self.loaded_dungeons[dungeonname] 183 self.current_dungeon.enter_dungeon(direction, floor, cell) 184 self.collect_game_data() 185 self.generate_party_graphics() 186 self.message_board.post("$PLAYERNAME enters $CURRENTDUNGEONNAME.", self.gamedata) 187 else: 188 return 1 189 190 def spawn_player(self, playerchar, cellpos, tilepos): 191 """ 192 Spawn the player in the current board at 193 the given cellpos and tilepos. Called on load 194 and game start. 'playerchar' is a party 195 character dict, which should contain info 196 needed to create a player. 197 """ 198 if self.current_board != None: 199 self.player = entity.PlayerEntity(self.sheets[playerchar["sheet"] + str(self.current_board.scale_factor)].sprites[tuple(playerchar["sprite"])], cellpos, tilepos, playerchar["color"]) 200 self.player_name = playerchar["name"] 201 self.player.rect.topleft = ((SCREEN_WIDTH // 3) - 6, (SCREEN_HEIGHT // 3) - 6)# TODO: Weirdly derived 202 self.current_board.position_offset = self.player.rect.topleft 203 self.current_board.position_to_tile(tilepos) 204 self.current_board.generate_tiles() 205 self.current_board.get_playerpos() 206 else: 207 return 1 208 209 def switch_mode(self, new_mode, data = None): 210 """ 211 Change the current state_mode, as well as load up 212 the elements of the new mode. This large method 213 should take account of every possible mode in the 214 game. Such a structure has no reason to exists e.g. 215 as a JSON file. The 'data' option contains the info 216 needed as part of a mode switch. This method handles 217 a change even if data is None. 218 """ 219 if new_mode == None: 220 return 221 else: 222 self.state_mode = new_mode 223 224 # TODO: Mode-specific switch logic 225 226 def generate_party_graphics(self): 227 """ 228 Generate fresh versions of the party graphics for 229 display in battles and dungeons. 230 """ 231 for p in self.party: 232 pg = entity.CustomSprite(self.sheets[self.party[p]["party_sheet"]].sprites[tuple(self.party[p]["party_sprite"])], (self.viewport_rect.left + (256 * self.party[p]["position"]), self.viewport_rect.center[1] + (48 * self.party[p]["row"])), self.party[p]["color"]) 233 self.party_group.add(pg) 234 235 def move_player_on_board(self, offset): 236 """ 237 Move the player from tile-to-tile relative to the 238 given offset. 239 """ 240 241 # First, prep messages 242 canmove = False 243 d = { (-1, 0) : "west", (1, 0) : "east", (0, -1) : "north", (0, 1) : "south" } 244 245 # Next, calculate the new tilepos for the player if it exists. 246 if self.player != None: 247 np = (self.player.tilepos[0] + offset[0], self.player.tilepos[1] + offset[1]) 248 249 # Next, if the new tilepos is within the bounds of the current cell and is passable, simply change the players tilepos to that value. 250 if np[1] >= 0 and np[1] < len(self.current_board.current_cell) and np[0] >= 0 and np[0] < len(self.current_board.current_cell[np[1]]): 251 if self.current_board.cellmap[self.player.cellpos][np[1]][np[0]][1] and (self.player.cellpos, np) not in self.current_board.npc_positions: 252 self.player.tilepos = np 253 canmove = True 254 255 # Otherwise, in case we are trying to move out of the current cell, calculate the relative offset of the new cellpos 256 # and the absolute position of the new cellpos using the player's current absolute cellpos. 257 else: 258 celloff = (-1 if offset[0] < 0 else 0 if offset[0] == 0 else 1, -1 if offset[1] < 0 else 0 if offset[1] == 0 else 1) 259 ncc = (self.player.cellpos[0] + celloff[0], self.player.cellpos[1] + celloff[1]) 260 261 # Check if we would land in this new cell. 262 if self.current_board.adjacent_cells[celloff] != None and ncc in self.current_board.cellmap.keys(): 263 ntp = (0 if celloff[0] > 0 else self.player.tilepos[0] if celloff[0] == 0 else self.current_board.cell_dimensions[0] - 1, 0 if celloff[1] > 0 else self.player.tilepos[1] if celloff[1] == 0 else self.current_board.cell_dimensions[1] - 1) 264 265 # If that tilepos is passable, move the player's cellpos and tilepos, then change the board cell. 266 if self.current_board.cellmap[ncc][ntp[1]][ntp[0]][1] and (ncc, ntp) not in self.current_board.npc_positions: 267 self.player.cellpos = ncc 268 self.player.tilepos = ntp 269 self.current_board.change_cell(self.player.cellpos) 270 canmove = True 271 272 # Finally, post the message, reposition, and check events 273 self.message_board.post("$PLAYERNAME moves " + d[offset] if offset in d.keys() and canmove else "$PLAYERNAME cannot move there!" if not canmove else "", self.gamedata) 274 self.current_board.position_to_tile(self.player.tilepos) 275 if canmove: 276 self.current_board.check_events_at_tilepos(self.player.tilepos) 277 self.pass_time() 278 279 def pass_time(self, time_mod = 1, take_turn = True): 280 """ 281 Passes time by 'time_mod', usually 1. If 'take_turn' 282 is true, the other entities on the current board 283 will move as well. 284 """ 285 286 # First, add time and calculate into date string. 287 self.old_gametime = self.gametime 288 self.gametime += time_mod 289 290 # Factor this time into the appropriate categories 291 # TODO: Time should be defined externally to allow for custom time systems 292 self.seconds = 10 * self.gametime 293 self.minutes = self.seconds // 60 294 self.hours = self.seconds // (60 * 60) 295 self.days = self.seconds // (60 * 60 * 25) 296 self.years = self.seconds // (60 * 60 * 25 * 351) 297 self.days -= self.years * 351 298 self.hours -= self.years * (25 * 351) 299 self.hours -= self.days * 25 300 self.minutes -= self.years * (60 * 25 * 351) 301 self.minutes -= self.days * (60 * 25) 302 self.minutes -= self.hours * 60 303 self.seconds -= self.years * (60 * 60 * 25 * 351) 304 self.seconds -= self.days * (60 * 60 * 25) 305 self.seconds -= self.hours * (60 * 60) 306 self.seconds -= self.minutes * 60 307 self.months = self.days // 39 308 self.days -= self.months * 39 309 310 # Then, if take_turn is true, take a turn for the other entities. 311 if self.state_mode in OVERHEAD_MODES and take_turn: 312 self.current_board.get_playerpos() 313 self.current_board.execute_npc_turns(time_mod) 314 315 def derive_datestring(self): 316 """ 317 Derive a datestring and a clockstring from 318 the current game time. 319 """ 320 mnth = self.month_names[self.months] 321 day_index = self.days // 13 322 day_rem = self.days % 13 323 if day_rem == 0: 324 day_meas = "" 325 else: 326 day_meas = str(13 - day_rem) + " day" + str("s" if 13 - day_rem != 1 else "") + " ere the " 327 if day_index < 2: 328 day_index += 1 329 else: 330 day_index = 0 331 self.datestring = day_meas + self.day_descriptors[day_index] + " of " + mnth 332 self.clockstring = "Year " + str(self.years) + " ]|[ " + str("0" if self.hours <= 9 else "") + str(self.hours) + ":" + str("0" if self.minutes <= 9 else "") + str(self.minutes) + ":" + str("0" if self.seconds <= 9 else "") + str(self.seconds) + " o'clock" 333 334 def post_message(self, text): 335 """ 336 This method is used by external objects to 337 post messages. 338 """ 339 self.message_board.post(text, self.gamedata) 340 341 def trigger_battle(self, partydef): 342 """ 343 Trigger a battle with the given party 344 definition. 345 """ 346 self.current_battle = battle.Battle(self, self.party, partydef) 347 self.collect_game_data() 348 self.post_message("Joe encounters $CURRENTBATTLEFORCES") 349 self.switch_mode(STATE_MODES.Battle_Mode) 350 351 def update_dynamic_ui(self, surface = None): 352 """ 353 Update the dynamic portions of the UI. 354 """ 355 356 # TODO: Lotsa hardcoding 357 if surface != None: 358 359 # To begin with, check if any elements in the dynamic UI need to be updated 360 if self.old_gametime != self.gametime: 361 self.derive_datestring() 362 self.old_gametime = self.gametime 363 self.date_element = self.small_ui_font.render(self.datestring, False, (255, 255, 255)) 364 self.date_element_pos = (696, 34) 365 self.clock_element = self.small_ui_font.render(self.clockstring, False, (255, 255, 255)) 366 self.clock_element_pos = (696, 48) 367 if self.old_location != self.current_board.display_name: 368 self.old_location = self.current_board.display_name 369 self.location_element = self.ui_font.render(self.current_board.display_name, False, (255, 255, 255)) 370 self.location_element_pos = (696, 16) 371 372 # After updating, blit the whole dynamic UI 373 if self.date_element != None: 374 surface.blit(self.date_element, self.date_element_pos) 375 if self.clock_element != None: 376 surface.blit(self.clock_element, self.clock_element_pos) 377 if self.location_element != None: 378 surface.blit(self.location_element, self.location_element_pos) 379 380 # Last is the party stat display 381 x_mod = 0 382 for c in self.party: 383 384 # First three meters 385 x2 = 0 386 for s in ("hp", "mp", "sp"): 387 if self.party[c][s] > self.party[c][s + "_mod"]: 388 yr = round(((self.party[c][s] - self.party[c][s + "_mod"]) / self.party[c][s]) * 38) 389 sq = pygame.Surface((8, yr)) 390 sq.fill((205 if s == "hp" else 0, 205 if s == "sp" else 0, 205 if s == "mp" else 0)) 391 surface.blit(sq, (83 + (x_mod * 137) + (x2 * 13), 587 + (38 - yr))) 392 x2 += 1 393 394 # XP meter 395 xpyr = round((self.party[c]["xp"] / calculate_xp_to_level(self.party[c]["level"])) * 38) 396 if xpyr > 0: 397 xpsq = pygame.Surface((8, xpyr)) 398 xpsq.fill((205, 205, 0)) 399 surface.blit(xpsq, (122 + (x_mod * 137), 587 + (38 - xpyr))) 400 401 # Character name 402 surface.blit(self.ui_font.render(self.party[c]["name"][0:min(len(self.party[c]["name"]), 14)], False, (0, 0, 0)), (15 + (x_mod * 137), 529)) 403 404 # Character stats 405 x3 = 0 406 y3 = 0 407 for statpair in (("phs", "mag"), ("agi", "chr"), ["luk"]): 408 for st in statpair: 409 statmod = self.party[c][st + "_mod"] 410 if statmod > 0: 411 statstring = str(self.party[c][st]) + "(-" + str(statmod) + ")" 412 elif statmod < 0: 413 statstring = str(self.party[c][st]) + "(+" + str(-statmod) + ")" 414 else: 415 statstring = str(self.party[c][st]) 416 rs = self.tiny_ui_font.render(statstring, False, (175, 20, 20) if statmod > 0 else (20, 235, 20) if statmod < 0 else (0, 0, 0)) 417 rsr = rs.get_rect() 418 rsr.topright = (68 + (x_mod * 137) + (62 * x3), 633 + (24 * y3)) 419 surface.blit(rs, rsr) 420 x3 += 1 421 x3 = 0 422 y3 += 1 423 424 # Move right to next character 425 x_mod += 1 426 427 def collect_game_data(self): 428 """ 429 Collect a bunch of info about the current 430 state of the game and game objects. This can 431 be safely called multiple times per frame. 432 """ 433 # TODO: This will see regular expansion 434 self.gamedata["$PLAYERNAME"] = self.player_name 435 self.gamedata["$CURRENTBOARDNAME"] = self.current_board.display_name if self.current_board != None else "!NOWHERE!" 436 self.gamedata["$CURRENTDUNGEONNAME"] = self.current_dungeon.display_name if self.current_dungeon != None else "!NODUNGEON!" 437 self.gamedata["$CURRENTBATTLEFORCES"] = self.current_battle.get_enemy_forces() if self.current_battle != None else "!NOBATTLE!" 438 439 def save_game(self, savename): 440 """ 441 Save the current game state as a JSON file. 442 """ 443 # First, determine what kind of location data we need to save 444 if self.state_mode in OVERHEAD_MODES: 445 at = { 446 "type" : "location", 447 "data" : { 448 "board" : self.current_board.name, 449 "cell" : self.player.cellpos, 450 "tile" : self.player.tilepos, 451 } 452 } 453 elif self.state_mode == STATE_MODES.Dungeon_Mode: 454 at = { 455 "type" : "dungeon", 456 "data" : { 457 "dungeon" : self.current_dungeon.name, 458 "direction" : self.current_dungeon.direction, 459 "floor" : self.current_dungeon.current_floor, 460 "cell" : self.current_dungeon.current_cell, 461 } 462 } 463 464 # Next, get progress for various loaded objects 465 dungeons = {} 466 for d in self.loaded_dungeons: 467 self.dungeons[d] = { "floor_factors" : self.loaded_dungeons[d].floor_factor_counters, "dungeon_flags" : self.loaded_dungeons[d].dungeon_flags } 468 469 # Then, construct the savestate and write it to a json 470 savestate = { 471 "story" : self.active_story, 472 "player" : self.player_name, 473 "gametime" : self.gametime, 474 "at" : at, 475 "party" : self.party, 476 "flags" : self.prog_flags, 477 "dungeons" : dungeons, 478 "newgame" : False 479 480 } 481 with open(os.path.join(SAVE_PATH, savename + ".json")) as sj: json.dump(savestate) 482 483 def load_game(self, savename): 484 """ 485 Load a save from JSON. 486 """ 487 # Open the json and apply universal values 488 if os.path.exists(os.path.join(SAVE_PATH, savename + ".json")): 489 with open(os.path.join(SAVE_PATH, savename + ".json")) as lj: saveraw = json.load(lj) 490 self.active_scenario = saveraw["scenario"] 491 self.player_name = saveraw["player"] 492 self.party = saveraw["party"] 493 494 # Load savedata values into already-loaded resources 495 for dun in self.loaded_dungeons: 496 self.loaded_dungeons[dun].load_from_savedata(saveraw["dungeons"][dun]) 497 498 # Load a board 499 if saveraw["at"]["type"] == "location": 500 self.switch_board(saveraw["at"]["data"]["board"], tuple(saveraw["at"]["data"]["cell"])) 501 self.spawn_player(saveraw["party"][saveraw["player"]], tuple(saveraw["at"]["data"]["cell"]), tuple(saveraw["at"]["data"]["tile"])) 502 self.switch_mode(STATE_MODES.Overworld_Mode) 503 504 # Load a dungeon 505 elif saveraw["at"]["type"] == "dungeon": 506 self.switch_dungeon(saveraw["at"]["data"]["dungeon"], False, saveraw["at"]["data"]["direction"], saveraw["at"]["data"]["floor"], tuple(saveraw["at"]["data"]["cell"])) 507 508 # Pass time up 509 self.pass_time(saveraw["gametime"], False) 510 else: 511 return 1 512 513 def shift_frames(self, framerate = FRAMERATE): 514 """ 515 Shift to the next frame of the game at the specified 516 framerate. 517 """ 518 self.frame_clock.tick(framerate) 519 520 def update_game(self): 521 """ 522 Update the game elements, the screen, and any other 523 objects. 524 """ 525 # First, fill the screen and viewport 526 self.screen.fill((33, 33, 33)) 527 self.viewport.fill((0, 0, 0)) 528 529 # Next, update the interface 530 self.interface.update_interface() 531 532 # Next, update and draw objects 533 self.screen.blit(self.viewport, self.viewport_rect) 534 if self.state_mode in OVERHEAD_MODES: 535 self.current_board.update_board(self.screen, self.viewport_rect) 536 if self.player != None: 537 self.player.update(self.screen, self.viewport_rect) 538 elif self.state_mode in PARTY_MODES: 539 if self.state_mode == STATE_MODES.Dungeon_Mode: 540 self.current_dungeon.update_dungeon(self.screen) 541 elif self.state_mode == STATE_MODES.Battle_Mode: 542 self.current_battle.update_battle(self.screen) 543 self.party_group.update(self.screen) 544 self.ui_group.update(self.screen) 545 self.message_board.update_message_board(self.screen) 546 self.update_dynamic_ui(self.screen) 547 548 # Next, get info about the game 549 self.collect_game_data() 550 551 # Last, update the screen 552 pygame.display.update() 553 554 def quit_game(self): 555 """ 556 Safely move the game into a quitting state. 557 """ 558 self.on = False 559 560 def mainloop(self): 561 """ 562 The main game loop. Run once to start the game. Also 563 safely handles shutting down if the game is turned 564 off. 565 """ 566 while self.on: 567 self.shift_frames() 568 self.interface.handle_events() 569 self.update_game() 570 pygame.quit()