# Screens left for saves compat screen chibi(chibi_object): zorder chibi_object.zorder sensitive False fixed: at chibi_object.transform fit_first True for d in chibi_object.displayables(): add d # frame: # Debug frame # background "#00ff0055" screen chibi_emote(emote, chibi_object): zorder chibi_object.zorder sensitive False add f"emo_{emote}": at emote_effect anchor (0.5, 1.0) pos chibi_object.pos zoom ChibiRoom.get().scale xzoom (-1 if chibi_object.flip else 1) if chibi_object.tag in ("genie", "snape"): offset (int(75*ChibiRoom.get().scale), int(-200*ChibiRoom.get().scale)) else: offset (int(50*ChibiRoom.get().scale), int(-170*ChibiRoom.get().scale)) label chibi_emote(emote, name): python: if emote == "hide": emote = None get_chibi_object(name).emote(emote) return default chibi_moves = {} init -1 python: def chibi_displayable(chibi_object): return At(Fixed(*chibi_object.displayables(), fit_first=True), chibi_object.transform) def chibi_emote_displayable(emote, chibi_object): if chibi_object.flip: xzoom = -1 else: xzoom = 1 if chibi_object.tag in ("genie", "snape"): offset = (int(75*ChibiRoom.get().scale), int(-200*ChibiRoom.get().scale)) else: offset = (int(50*ChibiRoom.get().scale), int(-170*ChibiRoom.get().scale)) # TODO: test in which order the transforms should be put return At(f"emo_{emote}", emote_effect, Transform(anchor=(.5, 1.), pos=chibi_object.pos, zoom=ChibiRoom.get().scale, xzoom=xzoom, offset=offset)) def update_chibi(name): """Update the chibi object for a given character.""" # TODO: Remove once chibi is ready. if "hooch" == name: return get_chibi_object(name).update() def get_chibi_object(name): """Get a chibi object by its character's name.""" name = f"{name}_chibi" c = getattr(renpy.store, name, None) if c and isinstance(c, Chibi): return c else: raise Exception(f"Chibi object not found. {name}") def complete_chibi_moves(**elapsed): """Resume old chibi action after (multiple) reduced move calls.""" q = [] for chibi, (t, a) in chibi_moves.items(): et = elapsed.get(chibi, 0) t -= et q.append((chibi, t, a)) q.sort(key=lambda x: x[1]) # Sort by time pt = 0 for chibi, t, a in q: renpy.pause(t - pt) pt += t get_chibi_object(chibi).do(a) chibi_moves.clear() class Chibi(object): """ Manages a character's chibi. Actions: * Represent what a chibi is doing. * Determine which transform is applied. * Allow layers to be changed to the relevant images (via update callback). There are two types of actions, one is used in place and the other while moving. Actions are defined in the `actions` dict as a tuple: (special, transform, move_action|loop_time). * `special` (bool) specifies whether layer images should come from a folder with the same name as the action. This can be useful to prevent repetitive code in update callbacks. * `transform` (string) is the name of the transform that is used for this action. It will be combined with a base transform. * `move_action` (string) if set, it's the action that will be used when the chibi starts moving after the current action. It should not be set for move actions. * `loop_time` (float) if set, it's the time in seconds for one animation loop of this action. Used to calculate movement time. It should only be set for move actions. Set to zero to disable time adjustments. Layers: A chibi is made up of one or more named layers. These are cleared on update and should be set by a callback function. * Layers can be accessed as `chibi_object[key]`. * A layer can be set to either a filename or any kind of displayable. * When setting an image filename, this class will look for it in `image_path` (or `image_path/action` if the action is special). * Adding `~` as a prefix to a filename will ignore the special action folder. This can be useful for images that are compatible with multiple actions. * Layers are updated whenever the action changes by calling `update_callback`, which is expected to set the layers again. """ actions = { None: (False, None, "walk"), "walk": (False, "chibi_walk", 0.32), "run": (False, "chibi_walk", 0), "fly": (True, "chibi_fly", "fly_move"), "fly_move": (True, "chibi_fly_move", 0), "wand": (True, "chibi_wand", "walk"), } def __init__(self, tag, layers, update_callback, zorder=3, speed=100, image_path=None, actions=None, places=None): self.tag = tag # Use a tuple/list to specify the order of layers in a dict self.layers_order = layers self.layers = dict.fromkeys(layers) self.update_callback = update_callback if image_path: self.image_path = image_path else: self.image_path = f"characters/{tag}/chibis" if actions: # Override class variable for this instance self.actions = Chibi.actions | actions self.zorder = zorder self.speed = speed # pixels/sec self.pos = (0,0) self.flip = False self.action = None self.action_info = self.resolve_action(None) self.special = None self.transform = None self.screen_tag = screen_tag = f"{tag}_chibi" config.tag_layer[screen_tag] = "screens" self.emote_tag = emote_tag = f"{tag}_chibi_emote" config.tag_layer[emote_tag] = "screens" def show(self): renpy.show(self.screen_tag, zorder=self.zorder, what=chibi_displayable(self)) def hide(self): renpy.hide(self.screen_tag) renpy.hide(self.emote_tag) def emote(self, emote=None): if renpy.showing(self.emote_tag): renpy.hide(self.emote_tag) renpy.pause(0.2) # Pause for duration of emote_effect if emote: renpy.show(self.emote_tag, zorder=self.zorder, what=chibi_emote_displayable(emote, self)) def update(self): self.clear() if self.update_callback: self.update_callback(self) def move(self, path=None, speed=1.0, reduce=False, action=None): """ Moves to a certain point or along a path. Movement takes into account the action, direction, time and transitions. Flipping is not possible mid-path, so the character should always face the same way. """ if isinstance(path, tuple): path = [path] real_path = [self.pos] for x, y in path: pos = self.resolve_position(x,y) real_path.append(pos) path = real_path flip = self.pos[0] <= path[-1][0] if self.flip != flip: self.flip = flip self.do(self.action) # Do a flip! renpy.with_statement(d3) # Resolve the move action old_action = self.action if action: move_action = action elif isinstance(self.action_info[2], str): # Action info provides a move action move_action = self.action_info[2] else: # Current action is already a move action move_action = self.action _, trans_name, loop_time = self.resolve_action(move_action) # Calculate movement time times = [] for i in range(len(path) - 1): dist = math.sqrt((path[i][0] - path[i+1][0])**2 + (path[i][1] - path[i+1][1])**2) time = dist / (float(self.speed) * speed) if loop_time > 0: # Round to nearest multiple of loop time to end on the right frame time = loop_time * round(time/loop_time) times.append(time) time = sum(times) # Apply the action with a transform trans = self.resolve_transform(trans_name, path, times) self.do(move_action, trans) self.position(*path[-1]) if reduce: # Reduce the pause and don't do the old action if reduce == "all": reduce = time elif isinstance(reduce, bool): reduce = 0 time -= float(reduce) chibi_moves[self.tag] = (reduce, old_action) if time > 0: renpy.pause(time) else: # Pause while moving and then do the old action renpy.pause(time) if old_action != move_action: self.do(old_action) renpy.with_statement(None) def do(self, action=None, trans=None): """Performs an action. Applies a transform and updates the chibi.""" self.set_action(action) # Set the transform (static version by default) if trans is None: trans = self.resolve_transform(self.action_info[1]) # Hide the screen so transform is reset properly self.hide() self.transform = trans self.update() self.show() def set_action(self, action): """Set the action state (no screen update).""" self.action = action self.action_info = self.resolve_action(action) self.special = self.action_info[0] def position(self, x=None, y=None, flip=None): """Set the position to be used on next update.""" (x,y) = self.resolve_position(x,y) if flip is not None: self.flip = flip self.pos = (x,y) def resolve_position(self, x=None, y=None): """Compute new position from place keywords (or just ints) for one or both of the coordinates.""" return ChibiRoom.place((x,y), self.pos) def resolve_transform(self, name, *args): """Get transform from the store by name and apply arguments.""" if name: trans = getattr(renpy.store, name, None) if isinstance(trans, renpy.display.transform.ATLTransform): # Combine with base transform return combine_transforms(self.base_transform(), trans(*args)) elif config.developer: raise Exception(f"Expected an ATL transform: {name}") # No transform was given or found return self.base_transform() def base_transform(self): scale = ChibiRoom.get().scale return chibi_base(self.pos, self.flip, scale) def resolve_action(self, name): """Get action info by name (falling back to "parent" action or default).""" while True: if name in self.actions: return self.actions[name] elif '_' in name: name = name.rsplit('_', 1)[0] else: return self.actions[None] def displayables(self): """Yields non-empty layers in an iterable manner.""" for k in self.layers_order: d = self.layers.get(k, None) if d is not None: yield d def clear(self): for k in self.layers.keys(): self.layers[k] = None def __getitem__(self, key): return self.layers[key] def __setitem__(self, key, value): if key not in self.layers: # Layer must be defined at init raise KeyError(key) if isinstance(value, str) and '.' in value: # Assume value is a filename and resolve it if value.startswith('~') or not self.special: # Avoid special directory value = self.image_path + "/" + value.lstrip("~/") else: value = self.image_path + "/" + self.action + "/" + value self.layers[key] = value class ChibiRoom(object): """Defines chibi scale factor and named positions (places) for a room.""" # Rooms by name rooms = dict() def __init__(self, name, scale, places): self.name = name self.scale = scale self.places = places ChibiRoom.rooms[self.name] = self def resolve(self, p, d, x_or_y): """Resolve p as coordinate, if None return d, else return p as integer.""" if not isinstance(p, int): if p is None: return d elif p in self.places: return self.places[p][int(x_or_y)] or d else: return int(p) return p @staticmethod def get(room=None): room = room or renpy.store.states.room chibi_room = ChibiRoom.rooms.get(room, None) if not chibi_room: raise Exception(f"Chibi room is not defined for {room}") return chibi_room @staticmethod def place(place, position, room=None): """Resolve place coordinates in the current room, or a given room (by name).""" chibi_room = ChibiRoom.get(room) x = chibi_room.resolve(place[0], position[0], False) y = chibi_room.resolve(place[1], position[1], True) return (x,y)