WTS/game/scripts/chibis/classes.rpy
2024-04-03 01:47:02 +02:00

346 lines
13 KiB
Plaintext

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)