playscii/game_world.py

1237 lines
49 KiB
Python

import importlib
import json
import math
import os
import sys
import time
import traceback
from collections import namedtuple
import sdl2
import collision
import game_hud
import game_object
import game_room
import game_util_objects
import vector
from art import ART_DIR
from camera import Camera
from charset import CHARSET_DIR
from grid import GameGrid
from palette import PALETTE_DIR
TOP_GAME_DIR = "games/"
DEFAULT_STATE_FILENAME = "start"
STATE_FILE_EXTENSION = "gs"
GAME_SCRIPTS_DIR = "scripts/"
SOUNDS_DIR = "sounds/"
# generic starter script with a GO and Player subclass
STARTER_SCRIPT = """
from game_object import GameObject
from game_util_objects import Player
class MyGamePlayer(Player):
"Generic starter player class for newly created games."
art_src = 'default_player'
# no "move" state art, so just use stand state for now
move_state = 'stand'
class MyGameObject(GameObject):
"Generic starter object class for newly created games."
def update(self):
# write "hello" in a color that shifts over time
color = self.art.palette.get_random_color_index()
self.art.write_string(0, 0, 3, 2, 'hello!', color)
# run parent class update
GameObject.update(self)
"""
# Quickie class to debug render order
RenderItem = namedtuple("RenderItem", ["obj", "layer", "sort_value"])
class GameCamera(Camera):
pan_friction = 0.2
use_bounds = False
class GameWorld:
"""
Holds global state for Game Mode. Spawns, manages, and renders GameObjects.
Properties serialized via WorldPropertiesObject.
Global state can be controlled via a WorldGlobalsObject.
"""
game_title = "Untitled Game"
"Title for game, shown in window titlebar when not editing"
gravity_x, gravity_y, gravity_z = 0.0, 0.0, 0.0
"Gravity applied to all objects who are affected by gravity."
bg_color = [0.0, 0.0, 0.0, 1.0]
"OpenGL wiewport color to render behind everything else, ie the void."
hud_class_name = "GameHUD"
"String name of HUD class to use"
properties_object_class_name = "WorldPropertiesObject"
globals_object_class_name = "WorldGlobalsObject"
"String name of WorldGlobalsObject class to use."
player_camera_lock = True
"If True, camera will be locked to player's location."
object_grid_snap = True
# editable properties
# TODO:
# update_when_unfocused = False
# "If True, game sim will update even when window doesn't have input focus"
draw_hud = True
allow_pause = True
"If False, user cannot pause game sim"
collision_enabled = True
"If False, CollisionLord won't bother thinking about collision at all."
# toggles for "show all" debug viz modes
show_collision_all = False
show_bounds_all = False
show_origin_all = False
show_all_rooms = False
"If True, show all rooms not just current one."
draw_debug_objects = True
"If False, objects with is_debug=True won't be drawn."
room_camera_changes_enabled = True
"If True, snap camera to new room's associated camera marker."
list_only_current_room_objects = False
"If True, list UI will only show objects in current room."
builtin_module_names = ["game_object", "game_util_objects", "game_hud", "game_room"]
builtin_base_classes = (
game_object.GameObject,
game_hud.GameHUD,
game_room.GameRoom,
)
def __init__(self, app):
self.app = app
"Application that created this world."
self.game_dir = None
"Currently loaded game directory."
self.sounds_dir = None
self.game_name = None
"Game's internal name, based on its directory."
self.selected_objects = []
self.hovered_objects = []
self.hovered_focus_object = None
"Set by check_hovers(), to the object that will be selected if edit mode user clicks"
self.last_click_on_ui = False
self.last_mouse_click_x, self.last_mouse_click_y = 0, 0
self.properties = None
"Our WorldPropertiesObject"
self.globals = None
"Our WorldGlobalsObject - not required"
self.camera = GameCamera(self.app)
self.grid = GameGrid(self.app)
self.grid.visible = False
self.player = None
self.paused = False
self._pause_time = 0
self.updates = 0
"Number of updates this we have performed."
self.modules = {
"game_object": game_object,
"game_util_objects": game_util_objects,
"game_hud": game_hud,
"game_room": game_room,
}
self.classname_to_spawn = None
self.objects = {}
"Dict of objects by name:object"
self.new_objects = {}
"Dict of just-spawned objects, added to above on update() after spawn"
self.rooms = {}
"Dict of rooms by name:room"
self.current_room = None
self.cl = collision.CollisionLord(self)
self.hud = None
self.art_loaded = []
self.drag_objects = {}
"Offsets for objects player is edit-dragging"
self.last_state_loaded = DEFAULT_STATE_FILENAME
def play_music(self, music_filename, fade_in_time=0):
"Play given music file in any SDL2_mixer-supported format."
music_filename = self.game_dir + SOUNDS_DIR + music_filename
self.app.al.set_music(music_filename)
self.app.al.start_music(music_filename)
def pause_music(self):
self.app.al.pause_music()
def resume_music(self):
self.app.al.resume_music()
def stop_music(self):
"Stop any currently playing music."
self.app.al.stop_all_music()
def is_music_playing(self):
"Return True if there is music playing."
return self.app.al.is_music_playing()
def pick_next_object_at(self, x, y):
"Return next unselected object at given point."
objects = self.get_objects_at(x, y)
# early out
if len(objects) == 0:
return None
# don't bother cycling if only one object found
if (
len(objects) == 1
and objects[0].selectable
and objects[0] not in self.selected_objects
):
return objects[0]
# cycle through objects at point til an unselected one is found
for obj in objects:
if not obj.selectable:
continue
if obj in self.selected_objects:
continue
return obj
return None
def get_objects_at(self, x, y, allow_locked=False):
"Return list of all objects whose bounds fall within given point."
objects = []
for obj in self.objects.values():
if obj.locked and not allow_locked:
continue
# only allow selecting of visible objects
# (can still be selected via list panel)
if obj.visible and obj.is_point_inside(x, y):
objects.append(obj)
# sort objects in Z, highest first
objects.sort(key=lambda obj: obj.z, reverse=True)
return objects
def select_click(self):
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, self.app.mouse_y)
# remember last place we clicked
self.last_mouse_click_x, self.last_mouse_click_y = x, y
if self.classname_to_spawn:
new_obj = self.spawn_object_of_class(self.classname_to_spawn, x, y)
if self.current_room:
self.current_room.add_object(new_obj)
self.app.ui.message_line.post_line("Spawned {}".format(new_obj.name))
return
objects = self.get_objects_at(x, y)
next_obj = self.pick_next_object_at(x, y)
if len(objects) == 0:
self.deselect_all()
# ctrl: unselect first selected object found under mouse
elif self.app.il.ctrl_pressed:
for obj in self.selected_objects:
if obj in objects:
self.deselect_object(obj)
break
# shift: add to current selection
elif self.app.il.shift_pressed:
self.select_object(next_obj)
# no mod keys: select only object under cursor, deselect all if none
elif not next_obj and len(objects) == 0:
self.deselect_all()
# special case: must use shift-click for objects
# beneath (lower Z) topmost
elif next_obj is not objects[0]:
pass
else:
self.select_object(next_obj)
# remember object offsets from cursor for dragging
for obj in self.selected_objects:
self.drag_objects[obj.name] = (obj.x - x, obj.y - y, obj.z - z)
def clicked(self, button):
# if edit UI is up, select stuff
if self.app.ui.is_game_edit_ui_visible():
if button == sdl2.SDL_BUTTON_LEFT:
self.select_click()
# else pass clicks to any objects under mouse
else:
x, y, z = vector.screen_to_world(
self.app, self.app.mouse_x, self.app.mouse_y
)
# 'locked" only relevant to edit mode, ignore it if in play mode
objects = self.get_objects_at(x, y, allow_locked=True)
for obj in objects:
if obj.handle_mouse_events and (
not obj.locked or not self.app.can_edit
):
obj.clicked(button, x, y)
if obj.consume_mouse_events:
break
def select_unclick(self):
# clicks on UI are consumed and flag world to not accept unclicks
# (keeps unclicks after dialog dismiss from deselecting objects)
if self.last_click_on_ui:
self.last_click_on_ui = False
# clear drag objects, since we're leaving valid drag context
# fixes unwanted drag after eg ESC exiting a menu
self.drag_objects.clear()
return
# if we're clicking to spawn something, don't drag/select
if self.classname_to_spawn:
return
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, self.app.mouse_y)
# remember selected objects now, they might be deselected but still
# need to have their collision turned back on.
selected_objects = self.selected_objects[:]
if len(self.selected_objects) > 0 and not self.app.il.shift_pressed:
# if mouse has traveled much since click, deselect all objects
# except one mouse is over.
dx = self.last_mouse_click_x - x
dy = self.last_mouse_click_y - y
if math.sqrt(dx**2 + dy**2) < 1.5:
for obj in self.get_objects_at(x, y):
if obj in self.selected_objects:
self.deselect_all()
self.select_object(obj)
break
# end drag, forget drag object offsets
self.drag_objects.clear()
for obj in selected_objects:
obj.enable_collision()
if obj.collision_shape_type == collision.CST_TILE:
obj.collision.create_shapes()
obj.collision.update()
def unclicked(self, button):
if self.app.ui.is_game_edit_ui_visible():
if button == sdl2.SDL_BUTTON_LEFT:
self.select_unclick()
else:
x, y, z = vector.screen_to_world(
self.app, self.app.mouse_x, self.app.mouse_y
)
objects = self.get_objects_at(x, y)
for obj in objects:
if obj.handle_mouse_events:
obj.unclicked(button, x, y)
def check_hovers(self):
"Update objects on their mouse hover status"
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, self.app.mouse_y)
new_hovers = self.get_objects_at(x, y)
# if this object will be selected on left click; draw bounds & label
if self.app.ui.is_game_edit_ui_visible():
next_obj = self.pick_next_object_at(x, y)
self.hovered_focus_object = next_obj
# if in play mode, notify objects who have begun to be hovered
else:
for obj in new_hovers:
if obj.handle_mouse_events and obj not in self.hovered_objects:
obj.hovered(x, y)
# check for objects un-hovered by this move
for obj in self.hovered_objects:
if obj.handle_mouse_events and obj not in new_hovers:
obj.unhovered(x, y)
self.hovered_objects = new_hovers
def mouse_wheeled(self, wheel_y):
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, self.app.mouse_y)
objects = self.get_objects_at(x, y, allow_locked=True)
for obj in objects:
if obj.handle_mouse_events and (not obj.locked or not self.app.can_edit):
obj.mouse_wheeled(wheel_y)
if obj.consume_mouse_events:
break
def mouse_moved(self, dx, dy):
if self.app.ui.active_dialog:
return
# bail if mouse didn't move (in world space - include camera) last input
if (
self.app.mouse_dx == 0
and self.app.mouse_dy == 0
and not self.camera.moved_this_frame
):
return
# if last onclick was a UI element, don't drag
if self.last_click_on_ui:
return
self.check_hovers()
# not dragging anything?
if len(self.selected_objects) == 0:
return
# 0-length drags cause unwanted snapping
if dx == 0 and dy == 0:
return
# set dragged objects to mouse + offset from mouse when drag started
x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, self.app.mouse_y)
for obj_name, offset in self.drag_objects.items():
obj = self.objects[obj_name]
if obj.locked:
continue
obj.disable_collision()
obj.x = x + offset[0]
obj.y = y + offset[1]
obj.z = z + offset[2]
if self.object_grid_snap:
obj.x = round(obj.x)
obj.y = round(obj.y)
# if odd width/height, origin will be between quads and
# edges will be off-grid; nudge so that edges are on-grid
if obj.art.width % 2 != 0:
obj.x += obj.art.quad_width / 2
if obj.art.height % 2 != 0:
obj.y += obj.art.quad_height / 2
def select_object(self, obj, force=False):
"Add given object to our list of selected objects."
if not self.app.can_edit:
return
if obj and (obj.selectable or force) and obj not in self.selected_objects:
self.selected_objects.append(obj)
self.app.ui.object_selection_changed()
def deselect_object(self, obj):
"Remove given object from our list of selected objects."
if obj in self.selected_objects:
self.selected_objects.remove(obj)
self.app.ui.object_selection_changed()
def deselect_all(self):
"Deselect all objects."
self.selected_objects = []
self.app.ui.object_selection_changed()
def create_new_game(self, new_game_dir, new_game_title):
"Create appropriate dirs and files for a new game, return success."
self.unload_game()
new_dir = self.app.documents_dir + TOP_GAME_DIR + new_game_dir + "/"
if os.path.exists(new_dir):
self.app.log("Game dir {} already exists!".format(new_game_dir))
return False
os.mkdir(new_dir)
os.mkdir(new_dir + ART_DIR)
os.mkdir(new_dir + GAME_SCRIPTS_DIR)
os.mkdir(new_dir + SOUNDS_DIR)
os.mkdir(new_dir + CHARSET_DIR)
os.mkdir(new_dir + PALETTE_DIR)
# create a generic starter script with a GO and Player subclass
f = open(new_dir + GAME_SCRIPTS_DIR + new_game_dir + ".py", "w")
f.write(STARTER_SCRIPT)
f.close()
# load game
self.set_game_dir(new_game_dir)
self.properties = self.spawn_object_of_class("WorldPropertiesObject")
self.objects.update(self.new_objects)
self.new_objects = {}
# HACK: set some property defaults, no idea why they don't take :[
self.collision_enabled = self.properties.collision_enabled = True
self.game_title = self.properties.game_title = new_game_title
self.save_to_file(DEFAULT_STATE_FILENAME)
return True
def unload_game(self):
"Unload currently loaded game."
for obj in self.objects.values():
obj.destroy()
self.cl.reset()
self.camera.reset()
self.player = None
self.globals = None
self.properties = None
if self.hud:
self.hud.destroy()
self.hud = None
self.objects, self.new_objects = {}, {}
self.rooms = {}
# art_loaded is cleared when game dir is set
self.selected_objects = []
self.app.al.stop_all_music()
def get_first_object_of_type(self, class_name, allow_subclasses=True):
"Return first object found with given class name."
c = self.get_class_by_name(class_name)
for obj in self.objects.values():
# use isinstance so we catch subclasses
if c and allow_subclasses:
if isinstance(obj, c):
return obj
elif type(obj).__name__ == class_name:
return obj
def get_all_objects_of_type(self, class_name, allow_subclasses=True):
"Return list of all objects found with given class name."
c = self.get_class_by_name(class_name)
objects = []
for obj in self.objects.values():
if c and allow_subclasses:
if isinstance(obj, c):
objects.append(obj)
elif type(obj).__name__ == class_name:
objects.append(obj)
return objects
def set_for_all_objects(self, name, value):
"Set given variable name to given value for all objects."
for obj in self.objects.values():
setattr(obj, name, value)
def edit_art_for_selected(self):
if len(self.selected_objects) == 0:
return
self.app.exit_game_mode()
for obj in self.selected_objects:
for art_filename in obj.get_all_art():
self.app.load_art_for_edit(art_filename)
def move_selected(self, move_x, move_y, move_z):
"Shift position of selected objects by given x,y,z amount."
for obj in self.selected_objects:
obj.x += move_x
obj.y += move_y
obj.z += move_z
def reset_game(self):
"Reset currently loaded game to last loaded state."
if self.game_dir:
self.load_game_state(self.last_state_loaded)
def set_game_dir(self, dir_name, reset=False):
"Load game from given game directory."
if self.game_dir and dir_name == self.game_dir:
self.load_game_state(DEFAULT_STATE_FILENAME)
return
# loading a new game, wipe art list
self.art_loaded = []
# check in user documents dir first
game_dir = TOP_GAME_DIR + dir_name
doc_game_dir = self.app.documents_dir + game_dir
for d in [doc_game_dir, game_dir]:
if not os.path.exists(d):
continue
self.game_dir = d
self.game_name = dir_name
if not d.endswith("/"):
self.game_dir += "/"
self.app.log("Game data folder is now {}".format(self.game_dir))
# set sounds dir before loading state; some obj inits depend on it
self.sounds_dir = self.game_dir + SOUNDS_DIR
if reset:
# load in a default state, eg start.gs
self.load_game_state(DEFAULT_STATE_FILENAME)
else:
# if no reset load submodules into namespace from the get-go
self._import_all()
self.classes = self._get_all_loaded_classes()
break
if not self.game_dir:
self.app.log("Couldn't find game directory {}".format(dir_name))
def _remove_non_current_game_modules(self):
"""
Remove modules from previously-loaded games from both sys and
GameWorld's dicts.
"""
modules_to_remove = []
games_dir_prefix = TOP_GAME_DIR.replace("/", "")
this_game_dir_prefix = "{}.{}".format(games_dir_prefix, self.game_name)
for module_name in sys.modules:
# remove any module that isn't for this game or part of its path
if (
module_name != games_dir_prefix
and module_name != this_game_dir_prefix
and module_name.startswith(games_dir_prefix)
and not module_name.startswith(this_game_dir_prefix + ".")
):
modules_to_remove.append(module_name)
for module_name in modules_to_remove:
sys.modules.pop(module_name)
if module_name in self.modules:
self.modules.pop(module_name)
def _get_game_modules_list(self):
"Return list of current game's modules from its scripts/ dir"
# build list of module files
modules_list = self.builtin_module_names[:]
# create appropriately-formatted python import path
module_path_prefix = "{}.{}.{}.".format(
TOP_GAME_DIR.replace("/", ""),
self.game_name,
GAME_SCRIPTS_DIR.replace("/", ""),
)
for filename in os.listdir(self.game_dir + GAME_SCRIPTS_DIR):
# exclude emacs temp files and special world start script
if not filename.endswith(".py"):
continue
if filename.startswith(".#"):
continue
new_module_name = module_path_prefix + filename.replace(".py", "")
modules_list.append(new_module_name)
return modules_list
def _import_all(self):
"""
Populate GameWorld.modules with the modules GW._get_all_loaded_classes
refers to when finding classes to spawn.
"""
# on first load, documents dir may not be in import path
if self.app.documents_dir not in sys.path:
sys.path += [self.app.documents_dir]
# clean modules dict before (re)loading anything
self._remove_non_current_game_modules()
# make copy of old modules table for import vs reload check
old_modules = self.modules.copy()
self.modules = {}
# load/reload new modules
for module_name in self._get_game_modules_list():
try:
# always reload built in modules
if (
module_name in self.builtin_module_names
or module_name in old_modules
):
m = importlib.reload(old_modules[module_name])
else:
m = importlib.import_module(module_name)
self.modules[module_name] = m
except Exception as e:
self.app.log_import_exception(e, module_name)
def toggle_pause(self):
"Toggles game pause state."
if not self.allow_pause:
return
self.paused = not self.paused
s = "Game {}paused.".format(["un", ""][self.paused])
self.app.ui.message_line.post_line(s)
def get_elapsed_time(self):
"""
Return total time world has been running (ie not paused) in
milliseconds.
"""
return self.app.get_elapsed_time() - self._pause_time
def enable_player_camera_lock(self):
if self.player:
self.camera.focus_object = self.player
def disable_player_camera_lock(self):
# change only if player has focus
if self.player and self.camera.focus_object is self.player:
self.camera.focus_object = None
def toggle_player_camera_lock(self):
"Toggle whether camera is locked to player's location."
if self.player and self.camera.focus_object is self.player:
self.disable_player_camera_lock()
else:
self.enable_player_camera_lock()
def toggle_grid_snap(self):
self.object_grid_snap = not self.object_grid_snap
def handle_input(self, event, shift_pressed, alt_pressed, ctrl_pressed):
# pass event's key to any objects that want to handle it
if event.type not in [sdl2.SDL_KEYDOWN, sdl2.SDL_KEYUP]:
return
key = sdl2.SDL_GetKeyName(event.key.keysym.sym).decode()
key = key.lower()
args = (key, shift_pressed, alt_pressed, ctrl_pressed)
for obj in self.objects.values():
if obj.handle_key_events:
if event.type == sdl2.SDL_KEYDOWN:
self.try_object_method(obj, obj.handle_key_down, args)
elif event.type == sdl2.SDL_KEYUP:
self.try_object_method(obj, obj.handle_key_up, args)
# TODO: handle_ functions for other types of input
def get_colliders_at_point(
self,
point_x,
point_y,
include_object_names=[],
include_class_names=[],
exclude_object_names=[],
exclude_class_names=[],
):
"""
Return lists of colliding objects and shapes at given point that pass
given filters.
Includes are processed before excludes.
"""
whitelist_objects = len(include_object_names) > 0
whitelist_classes = len(include_class_names) > 0
blacklist_objects = len(exclude_object_names) > 0
blacklist_classes = len(exclude_class_names) > 0
# build list of objects to check
# always ignore non-colliders
colliders = []
for obj in self.objects.values():
if obj.should_collide():
colliders.append(obj)
check_objects = []
if whitelist_objects or whitelist_classes:
# list of class names -> list of classes
include_classes = [
self.get_class_by_name(class_name) for class_name in include_class_names
]
# only given objects of given classes
if whitelist_objects and whitelist_classes:
for obj_name in include_object_names:
obj = self.objects[obj_name]
for c in include_classes:
if isinstance(obj, c) and obj not in check_objects:
check_objects.append(obj)
# only given objects of any class
elif whitelist_objects and not whitelist_classes:
check_objects += [
self.objects[obj_name] for obj_name in include_object_names
]
# all colliders of given classes
elif whitelist_classes:
for obj in colliders:
for c in include_classes:
if isinstance(obj, c) and obj not in check_objects:
check_objects.append(obj)
else:
check_objects = colliders[:]
check_objects_unfiltered = check_objects[:]
if blacklist_objects or blacklist_classes:
exclude_classes = [
self.get_class_by_name(class_name) for class_name in exclude_class_names
]
for obj in check_objects_unfiltered:
if obj.name in exclude_object_names:
check_objects.remove(obj)
continue
for c in exclude_classes:
if isinstance(obj, c):
check_objects.remove(obj)
# check all objects that passed the filter(s) and build hit list
hit_objects = []
hit_shapes = []
for obj in check_objects:
# check bounds
if not obj.is_point_inside(point_x, point_y):
continue
# point overlaps with tile collision?
if obj.collision_shape_type == collision.CST_TILE:
tile_x, tile_y = obj.get_tile_at_point(point_x, point_y)
if (tile_x, tile_y) in obj.collision.tile_shapes:
hit_objects.append(obj)
hit_shapes.append(obj.collision.tile_shapes[(tile_x, tile_y)])
else:
# point overlaps with primitive shape(s)?
for shape in obj.collision.shapes:
if shape.is_point_inside(point_y, point_y):
hit_objects.append(obj)
hit_shapes.append(shape)
return hit_objects, hit_shapes
def frame_begin(self):
"Run at start of game loop iteration, before input/update/render."
for obj in self.objects.values():
obj.art.updated_this_tick = False
obj.frame_begin()
def frame_update(self):
for obj in self.objects.values():
obj.frame_update()
def try_object_method(self, obj, method, args=()):
"Try to run given object's given method, printing error if encountered."
# print('running %s.%s' % (obj.name, method.__name__))
try:
method(*args)
if method.__name__ == "update":
obj.last_update_failed = False
except Exception:
if method.__name__ == "update" and obj.last_update_failed:
return
obj.last_update_failed = True
for line in traceback.format_exc().split("\n"):
if (
line
and "try_object_method" not in line
and line.strip() != "method()"
):
self.app.log(line.rstrip())
s = "Error in {}.{}! See console.".format(obj.name, method.__name__)
self.app.ui.message_line.post_line(s, 10, True)
def pre_update(self):
"Run GO and Room pre_updates before GameWorld.update"
# add newly spawned objects to table
self.objects.update(self.new_objects)
self.new_objects = {}
# run pre_first_update / pre_update on all appropriate objects
for obj in self.objects.values():
if not obj.pre_first_update_run:
self.try_object_method(obj, obj.pre_first_update)
obj.pre_first_update_run = True
# only run pre_update if not paused
elif not self.paused and (
obj.is_in_current_room() or obj.update_if_outside_room
):
# update timers
# (copy timers list in case a timer removes itself from object)
for timer in list(obj.timer_functions_pre_update.values())[:]:
timer.update()
obj.pre_update()
for room in self.rooms.values():
if not room.pre_first_update_run:
room.pre_first_update()
room.pre_first_update_run = True
def update(self):
self.mouse_moved(self.app.mouse_dx, self.app.mouse_dy)
if self.properties:
self.properties.update_from_world()
if not self.paused:
# update objects based on movement, then resolve collisions
for obj in self.objects.values():
if obj.is_in_current_room() or obj.update_if_outside_room:
# update timers
for timer in list(obj.timer_functions_update.values())[:]:
timer.update()
self.try_object_method(obj, obj.update)
# subclass update may not call GameObject.update,
# set last update time here once we're sure it's done
obj.last_update_end = self.get_elapsed_time()
if self.collision_enabled:
self.cl.update()
for room in self.rooms.values():
room.update()
# display debug text for selected object(s)
for obj in self.selected_objects:
s = obj.get_debug_text()
if s:
self.app.ui.debug_text.post_lines(s)
# remove objects marked for destruction
to_destroy = []
for obj in self.objects.values():
if obj.should_destroy:
to_destroy.append(obj.name)
for obj_name in to_destroy:
self.objects.pop(obj_name)
if len(to_destroy) > 0:
self.app.ui.edit_list_panel.items_changed()
if self.hud:
self.hud.update()
if self.paused:
self._pause_time += self.app.get_elapsed_time() - self.app.last_time
else:
self.updates += 1
def post_update(self):
"Run after GameWorld.update."
if self.paused:
return
for obj in self.objects.values():
if obj.is_in_current_room() or obj.update_if_outside_room:
# update timers
for timer in list(obj.timer_functions_post_update.values())[:]:
timer.update()
obj.post_update()
def render(self):
"Sort and draw all objects in Game Mode world."
visible_objects = []
for obj in self.objects.values():
if obj.should_destroy:
continue
obj.update_renderables()
# filter out objects outside current room here
# (if no current room or object is in no rooms, render it always)
in_room = self.current_room is None or obj.is_in_current_room()
hide_debug = obj.is_debug and not self.draw_debug_objects
# respect object's "should render at all" flag
if obj.visible and not hide_debug and (self.show_all_rooms or in_room):
visible_objects.append(obj)
#
# process non "Y sort" objects first
#
draw_order = []
collision_items = []
y_objects = []
for obj in visible_objects:
if obj.y_sort:
y_objects.append(obj)
continue
for i, z in enumerate(obj.art.layers_z):
# ignore invisible layers
if not obj.art.layers_visibility[i]:
continue
# only draw collision layer if show collision is set, OR if
# "draw collision layer" is set
if (
obj.collision_shape_type == collision.CST_TILE
and obj.col_layer_name == obj.art.layer_names[i]
and not obj.draw_col_layer
):
if obj.show_collision:
item = RenderItem(obj, i, z + obj.z)
collision_items.append(item)
continue
item = RenderItem(obj, i, z + obj.z)
draw_order.append(item)
draw_order.sort(key=lambda item: item.sort_value, reverse=False)
#
# process "Y sort" objects
#
y_objects.sort(key=lambda obj: obj.y, reverse=True)
# draw layers of each Y-sorted object in Z order
for obj in y_objects:
items = []
for i, z in enumerate(obj.art.layers_z):
if not obj.art.layers_visibility[i]:
continue
if (
obj.collision_shape_type == collision.CST_TILE
and obj.col_layer_name == obj.art.layer_names[i]
):
if obj.show_collision:
item = RenderItem(obj, i, 0)
collision_items.append(item)
continue
item = RenderItem(obj, i, z)
items.append(item)
items.sort(key=lambda item: item.sort_value, reverse=False)
for item in items:
draw_order.append(item)
for item in draw_order:
item.obj.render(item.layer)
self.grid.render()
#
# draw debug stuff: collision tiles, origins/boxes, debug lines
#
for item in collision_items:
item.obj.render(item.layer)
for obj in self.objects.values():
obj.render_debug()
if self.hud and self.draw_hud:
self.hud.render()
def save_last_state(self):
"Save over last loaded state."
# strip down to base filename w/o extension :/
last_state = self.last_state_loaded
last_state = os.path.basename(last_state)
last_state = os.path.splitext(last_state)[0]
self.save_to_file(last_state)
def save_to_file(self, filename=None):
"Save current world state to a file."
objects = []
for obj in self.objects.values():
if obj.should_save:
objects.append(obj.get_dict())
d = {"objects": objects}
# save rooms if any exist
if len(self.rooms) > 0:
rooms = [room.get_dict() for room in self.rooms.values()]
d["rooms"] = rooms
if self.current_room:
d["current_room"] = self.current_room.name
if filename and filename != "":
if not filename.endswith(STATE_FILE_EXTENSION):
filename += "." + STATE_FILE_EXTENSION
filename = "{}{}".format(self.game_dir, filename)
else:
# state filename example:
# games/mytestgame2/1431116386.gs
timestamp = int(time.time())
filename = "{}{}.{}".format(self.game_dir, timestamp, STATE_FILE_EXTENSION)
json.dump(d, open(filename, "w"), sort_keys=True, indent=1)
self.app.log("Saved game state {} to disk.".format(filename))
self.app.update_window_title()
def _get_all_loaded_classes(self):
"""
Return classname,class dict of all GameObject classes in loaded modules.
"""
classes = {}
for module in self.modules.values():
for k, v in module.__dict__.items():
# skip anything that's not a game class
if type(v) is not type:
continue
base_classes = (
game_object.GameObject,
game_hud.GameHUD,
game_room.GameRoom,
)
# TODO: find out why above works but below doesn't!! O___O
# base_classes = self.builtin_base_classes
if issubclass(v, base_classes):
classes[k] = v
return classes
def get_class_by_name(self, class_name):
"Return Class object for given class name."
return self.classes.get(class_name, None)
def reset_object_in_place(self, obj):
"""
"Reset" given object to its class defaults.
Actually destroys object in place and creates a new one.
"""
x, y = obj.x, obj.y
obj_class = obj.__class__.__name__
spawned = self.spawn_object_of_class(obj_class, x, y)
if spawned:
self.app.log("{} reset to class defaults".format(obj.name))
if obj is self.player:
self.player = spawned
obj.destroy()
def duplicate_selected_objects(self):
"Duplicate all selected objects. Calls GW.duplicate_object"
new_objects = []
for obj in self.selected_objects:
new_objects.append(self.duplicate_object(obj))
# report on objects created
if len(new_objects) == 1:
self.app.log("{} created from {}".format(new_objects[0].name, obj.name))
elif len(new_objects) > 1:
self.app.log("{} new objects created".format(len(new_objects)))
def duplicate_object(self, obj):
"Create a duplicate of given object."
d = obj.get_dict()
# offset new object's location
x, y = d["x"], d["y"]
x += obj.renderable.width
y -= obj.renderable.height
d["x"], d["y"] = x, y
# new object needs a unique name, use a temp one until object exists
# for real and we can give it a proper, more-likely-to-be-unique one
d["name"] = obj.name + " TEMP COPY NAME"
new_obj = self.spawn_object_from_data(d)
# give object a non-duplicate name
self.rename_object(new_obj, new_obj.get_unique_name())
# tell object's rooms about it
for room_name in new_obj.rooms:
self.world.rooms[room_name].add_object(new_obj)
# update list after changes have been applied to object
self.app.ui.edit_list_panel.items_changed()
return new_obj
def rename_object(self, obj, new_name):
"Give specified object a new name. Doesn't accept already-in-use names."
self.objects.update(self.new_objects)
for other_obj in self.objects.values():
if other_obj is not self and other_obj.name == new_name:
self.app.ui.message_line.post_line(
"Can't rename {} to {}, name already in use".format(
obj.name, new_name
)
)
return
self.objects.pop(obj.name)
old_name = obj.name
obj.name = new_name
self.objects[obj.name] = obj
for room in self.rooms.values():
if obj in room.objects.values():
room.objects.pop(old_name)
room.objects[obj.name] = self
def spawn_object_of_class(self, class_name, x=None, y=None):
"Spawn a new object of given class name at given location."
if class_name not in self.classes:
# no need for log here, import_all prints exception cause
# self.app.log("Couldn't find class %s" % class_name)
return
d = {"class_name": class_name}
if x is not None and y is not None:
d["x"], d["y"] = x, y
new_obj = self.spawn_object_from_data(d)
self.app.ui.edit_list_panel.items_changed()
return new_obj
def spawn_object_from_data(self, object_data):
"Spawn a new object with properties populated from given data dict."
# load module and class
class_name = object_data.get("class_name", None)
if not class_name or class_name not in self.classes:
# no need for log here, import_all prints exception cause
# self.app.log("Couldn't parse class %s" % class_name)
return
obj_class = self.classes[class_name]
# pass in object data
new_object = obj_class(self, object_data)
return new_object
def add_room(self, new_room_name, new_room_classname="GameRoom"):
"Add a new Room with given name of (optional) given class."
if new_room_name in self.rooms:
self.log("Room called {} already exists!".format(new_room_name))
return
new_room_class = self.classes[new_room_classname]
new_room = new_room_class(self, new_room_name)
self.rooms[new_room.name] = new_room
def remove_room(self, room_name):
"Delete Room with given name."
if room_name not in self.rooms:
return
room = self.rooms.pop(room_name)
if room is self.current_room:
self.current_room = None
room.destroy()
def change_room(self, new_room_name):
"Set world's current active room to Room with given name."
if new_room_name not in self.rooms:
self.app.log("Couldn't change to missing room {}".format(new_room_name))
return
old_room = self.current_room
self.current_room = self.rooms[new_room_name]
# tell old and new rooms they've been exited and entered, respectively
if old_room:
old_room.exited(self.current_room)
self.current_room.entered(old_room)
def rename_room(self, room, new_room_name):
"Rename given Room to new given name."
old_name = room.name
room.name = new_room_name
self.rooms.pop(old_name)
self.rooms[new_room_name] = room
# update all objects in this room
for obj in self.objects.values():
if old_name in obj.rooms:
obj.rooms.pop(old_name)
obj.rooms[new_room_name] = room
def load_game_state(self, filename=DEFAULT_STATE_FILENAME):
"Load game state with given filename."
if not os.path.exists(filename):
filename = self.game_dir + filename
if not filename.endswith(STATE_FILE_EXTENSION):
filename += "." + STATE_FILE_EXTENSION
self.app.enter_game_mode()
self.unload_game()
# tell list panel to reset, its contents might get jostled
self.app.ui.edit_list_panel.game_reset()
# import all submodules and catalog classes
self._import_all()
self.classes = self._get_all_loaded_classes()
try:
d = json.load(open(filename))
# self.app.log('Loading game state %s...' % filename)
except:
self.app.log("Couldn't load game state from {}".format(filename))
# self.app.log(sys.exc_info())
return
errors = False
# spawn objects
for obj_data in d["objects"]:
obj = self.spawn_object_from_data(obj_data)
if not obj:
errors = True
# spawn a WorldPropertiesObject if one doesn't exist
for obj in self.new_objects.values():
if type(obj).__name__ == self.properties_object_class_name:
self.properties = obj
break
if not self.properties:
self.properties = self.spawn_object_of_class(
self.properties_object_class_name, 0, 0
)
# spawn a WorldGlobalStateObject
self.globals = self.spawn_object_of_class(self.globals_object_class_name, 0, 0)
# just for first update, merge new objects list into objects list
self.objects.update(self.new_objects)
# create rooms
for room_data in d.get("rooms", []):
# get room class
room_class_name = room_data.get("class_name", None)
room_class = self.classes.get(room_class_name, game_room.GameRoom)
room = room_class(self, room_data["name"], room_data)
self.rooms[room.name] = room
start_room = self.rooms.get(d.get("current_room", None), None)
if start_room:
self.change_room(start_room.name)
# spawn hud
hud_class = self.classes[d.get("hud_class", self.hud_class_name)]
self.hud = hud_class(self)
self.hud_class_name = hud_class.__name__
if not errors and self.app.init_success:
self.app.log("Loaded game state from {}".format(filename))
self.last_state_loaded = filename
self.set_for_all_objects("show_collision", self.show_collision_all)
self.set_for_all_objects("show_bounds", self.show_bounds_all)
self.set_for_all_objects("show_origin", self.show_origin_all)
self.app.update_window_title()
self.app.ui.edit_list_panel.items_changed()
# self.report()
def report(self):
"Print (not log) information about current world state."
print("--------------\n{} report:".format(self))
obj_arts, obj_rends, obj_dbg_rends, obj_cols, obj_col_rends = 0, 0, 0, 0, 0
attachments = 0
# create merged dict of existing and just-spawned objects
all_objects = self.objects.copy()
all_objects.update(self.new_objects)
print("{} objects:".format(len(all_objects)))
for obj in all_objects.values():
obj_arts += len(obj.arts)
if obj.renderable is not None:
obj_rends += 1
if obj.origin_renderable is not None:
obj_dbg_rends += 1
if obj.bounds_renderable is not None:
obj_dbg_rends += 1
if obj.collision:
obj_cols += 1
obj_col_rends += len(obj.collision.renderables)
attachments += len(obj.attachments)
print(
"""
{} arts in objects, {} arts loaded,
{} HUD arts, {} HUD renderables,
{} renderables, {} debug renderables,
{} collideables, {} collideable viz renderables,
{} attachments""".format(
obj_arts,
len(self.art_loaded),
len(self.hud.arts),
len(self.hud.renderables),
obj_rends,
obj_dbg_rends,
obj_cols,
obj_col_rends,
attachments,
)
)
self.cl.report()
print(
"{} charsets loaded, {} palettes".format(
len(self.app.charsets), len(self.app.palettes)
)
)
print("{} arts loaded for edit".format(len(self.app.art_loaded_for_edit)))
def toggle_all_origin_viz(self):
"Toggle visibility of XYZ markers for all object origins."
self.show_origin_all = not self.show_origin_all
self.set_for_all_objects("show_origin", self.show_origin_all)
def toggle_all_bounds_viz(self):
"Toggle visibility of boxes for all object bounds."
self.show_bounds_all = not self.show_bounds_all
self.set_for_all_objects("show_bounds", self.show_bounds_all)
def toggle_all_collision_viz(self):
"Toggle visibility of debug lines for all object Collideables."
self.show_collision_all = not self.show_collision_all
self.set_for_all_objects("show_collision", self.show_collision_all)
def destroy(self):
self.unload_game()
self.art_loaded = []