LoafyLemon
2acccbea5a
* Added DollBodypart class to allow more extensive modding support for characters, along with dedicates layers for breasts, hips and so on... * Added more bangs into DollClothDynamic, allowing tracking of singular objects and the ability to chainload the IDs * Added get_character_body global method * Added istype global method * Improved caching, layering for dynamic clothes * Improved hashing for Doll instances * Improved readability and improved internal naming convention for Doll-type classes * Removed dedicated buttplug slot (superseded by multislot reimplementation) * Fixed clothing supplied skin layer issues with zorders * Fixed DollClothDynamic __repr__ lack of quotes * Fixed Hermione's open_wide_tongue mouth * Fixed Hermione's tattoos missing images * Fixed Cho's 'goodbye' after-summon skit using wrong number of expressions
274 lines
10 KiB
Plaintext
274 lines
10 KiB
Plaintext
init python:
|
|
class DollCloth(DollMethods):
|
|
layer_types = {
|
|
"mask": "-1",
|
|
"skin": 10,
|
|
"armfix": "+1",
|
|
"outline": None,
|
|
"extra": None,
|
|
"overlay": None,
|
|
}
|
|
|
|
layer_modifiers = {
|
|
"back": "-300",
|
|
"front": "+300",
|
|
"zorder": None,
|
|
}
|
|
|
|
def __init__(self, name, categories, type, id, color, zorder=None, unlocked=False, level=0, blacklist=[], modpath=None, parent=None):
|
|
self.name = name
|
|
self.categories = categories
|
|
self.type = type
|
|
self.id = id
|
|
self.color = color
|
|
self.unlocked = unlocked
|
|
self.level = level
|
|
self.blacklist = blacklist
|
|
self.modpath = posixpath.normpath(modpath) if modpath else ""
|
|
self.parent = parent
|
|
|
|
self.char = eval(name)
|
|
self.color_default = [x[:] for x in color] if color else None
|
|
self.zorder = zorder or self.char.states[type][1]
|
|
self.seen = self.unlocked
|
|
self._hash = self.generate_hash()
|
|
|
|
# Add to character wardrobe and unordered list
|
|
if not parent:
|
|
self.char.wardrobe.setdefault(self.categories[0], {}).setdefault(self.categories[1], []).append(self)
|
|
self.char.wardrobe_list.append(self)
|
|
|
|
# Define new item slot type if doesn't exist
|
|
self.char.states.setdefault(self.type, [None, (self.zorder or 1), True])
|
|
|
|
def __repr__(self):
|
|
return f"DollCloth(name={self.name}, categories={self.categories}, type={self.type}, id={self.id}, color={self.color}, zorder={self.zorder}, unlocked={self.unlocked}, level={self.level}, blacklist={self.blacklist}, parent={self.parent}, modpath={self.modpath or None})"
|
|
|
|
def __hash__(self):
|
|
return self._hash
|
|
|
|
def __eq__(self, obj):
|
|
if not isinstance(obj, DollCloth):
|
|
return NotImplemented
|
|
return self._hash == obj._hash
|
|
|
|
def generate_hash(self):
|
|
salt = str( [self.name, self.char.pose, self.type, self.id, str(self.color), str(self.char.body._hash)] )
|
|
return hash(salt)
|
|
|
|
@functools.cache
|
|
def get_layers(self, hash):
|
|
path = os.path.join(self.modpath, "characters", self.name, self.char.pose, "clothes", self.type, self.id)
|
|
|
|
extensions = self.extensions
|
|
types = self.layer_types
|
|
modifiers = self.layer_modifiers
|
|
|
|
layers = {}
|
|
for f in renpy.list_files():
|
|
fp, fn = os.path.split(f)
|
|
fn, ext = os.path.splitext(fn)
|
|
|
|
if not fp == path or not ext in extensions:
|
|
continue
|
|
|
|
# For user's sake, simplicty, and compatibility reasons,
|
|
# we sort the layers in the code instead.
|
|
#
|
|
# Each file name part represents the following:
|
|
# layertype_subtype*_zorder*_INT*
|
|
#
|
|
# Parts marked with * are optional and can be inserted out of order,
|
|
# with the exception of zorder, which requires an integer appendix.
|
|
#
|
|
# Example valid file name combinations:
|
|
#
|
|
# 0.webp
|
|
# 0_zorder_15.webp
|
|
# 0_front.webp
|
|
# outline.webp
|
|
# outline_back.webp
|
|
#
|
|
# If multiple files exist but with different extension,
|
|
# only the first file will be added to the dictionary.
|
|
|
|
ltype, *tails = fn.rsplit("_")
|
|
|
|
if not ltype.isdigit() and not ltype in types:
|
|
print("Invalid layer type for file: {}".format(f))
|
|
continue
|
|
|
|
zorder = z if (z := types.get(ltype)) is not None else self.zorder
|
|
|
|
if isinstance(zorder, str):
|
|
# Note: Layer uses relative zorder if it's passed as a string
|
|
zorder = self.zorder + int(zorder)
|
|
|
|
if tails:
|
|
lmodifier, *tails = tails
|
|
|
|
if not lmodifier in modifiers:
|
|
print("Invalid modifier for file: {}".format(f))
|
|
continue
|
|
|
|
zorder_mod = modifiers.get(lmodifier)
|
|
zorder = (zorder + int(zorder_mod)) if lmodifier != "zorder" else int(tails[-1])
|
|
layers.setdefault("_".join([ltype, lmodifier]), [f, zorder])
|
|
else:
|
|
layers.setdefault(ltype, [f, zorder])
|
|
|
|
return layers
|
|
|
|
@functools.cache
|
|
def build_image(self, hash, matrix=None):
|
|
if matrix is None:
|
|
matrix = self.char.body.hue
|
|
|
|
processors = {
|
|
"skin": lambda file, _: Transform(file, matrixcolor=matrix),
|
|
"armfix": lambda file, _: Transform(file, matrixcolor=matrix),
|
|
"colored": lambda file, n: self.apply_color(file, int(n)),
|
|
"default": lambda file, _: Image(file),
|
|
}
|
|
|
|
layers = self.get_layers(hash)
|
|
|
|
sprites = []
|
|
for identifier, (file, zorder) in layers.items():
|
|
|
|
if ((n := identifier.rsplit("_", 1)[0]).isdigit()):
|
|
processor = processors["colored"]
|
|
else:
|
|
processor = processors.get(identifier, processors["default"])
|
|
|
|
processed_file = processor(file, n)
|
|
sprites.append((identifier, processed_file, zorder))
|
|
|
|
return sprites
|
|
|
|
@property
|
|
def image(self):
|
|
if not renpy.is_skipping() and self.is_stale():
|
|
hash = self._hash
|
|
|
|
sprites = self.build_image(hash)
|
|
sprites.sort(key=itemgetter(2))
|
|
sprites = [x[1] for x in sprites]
|
|
|
|
self._image = Fixed(*sprites, fit_first=True)
|
|
return self._image
|
|
|
|
@functools.cache
|
|
def build_icon(self, hash):
|
|
matrix = SaturationMatrix(0.0)
|
|
sprites = [i for i in self.build_image(hash, matrix=matrix) if not i[0] == "mask"]
|
|
|
|
try:
|
|
bounds = self.get_layers(hash).get("outline", [sprites[0][1]])[0]
|
|
except IndexError:
|
|
return Text(f"Error\n{{color=#00ffff}}{{size=-6}}ID:{self.id}{{/size}}{{/color}}", color="#ff0000")
|
|
|
|
sprites.extend(self.char.body.build_image(self.char.body._hash, matrix=matrix))
|
|
sprites.sort(key=itemgetter(2))
|
|
|
|
wmax, hmax = self.sizes
|
|
wmin = hmin = 96
|
|
|
|
x, y, w, h = crop_whitespace(bounds)
|
|
xoffset, yoffset = w/2, h/2
|
|
|
|
w = h = max(w, h, wmin, hmin)
|
|
|
|
w = max(wmin, w + w/2)
|
|
h = max(hmin, h + h/2)
|
|
|
|
x = clamp( (x - w/2) + xoffset, 0, wmax)
|
|
y = clamp( (y - h/2) + yoffset, 0, hmax)
|
|
|
|
# Forbid exceeding the image height.
|
|
if y+h > hmax:
|
|
y = hmax-h
|
|
|
|
return Transform(Fixed(*[i[1] for i in sprites], fit_first=True), crop=(x, y, w, h))
|
|
|
|
@property
|
|
def icon(self):
|
|
return self.build_icon(self._hash)
|
|
|
|
def apply_color(self, img, n):
|
|
"""Takes image and int layer number. Used internally."""
|
|
c = TintMatrix(tuple(self.color[n]))
|
|
return Transform(img, matrixcolor=c)
|
|
|
|
def set_color(self, n):
|
|
"""Takes int layer number for manual color picking or a list to replace the cloth color in its entirety."""
|
|
if isinstance(n, int):
|
|
col = Color(tuple(self.color[n]))
|
|
dcol = Color(tuple(self.color_default[n]))
|
|
|
|
cp.live_replace(col)
|
|
cp.start_replace(col)
|
|
cp.default_replace(dcol)
|
|
|
|
renpy.show_screen("colorpickerscreen", self)
|
|
|
|
while True:
|
|
action, value = ui.interact()
|
|
|
|
if action == "layer":
|
|
n = value
|
|
col = Color(tuple(self.color[value]))
|
|
dcol = Color(tuple(self.color_default[n]))
|
|
|
|
cp.live_replace(col)
|
|
cp.start_replace(col)
|
|
cp.default_replace(dcol)
|
|
elif action == "released":
|
|
self.color[n] = [int(255*x) for x in value.rgba]
|
|
self.is_stale()
|
|
elif action == "replace":
|
|
self.color[n] = [int(255*x) for x in value.rgba]
|
|
cp.live_replace(value)
|
|
self.is_stale()
|
|
elif action == "finish":
|
|
break
|
|
|
|
renpy.hide_screen("colorpickerscreen")
|
|
elif isinstance(n, list):
|
|
self.color = [x[:] for x in n]
|
|
self.is_stale()
|
|
|
|
def reset_color(self, n=None):
|
|
"""Reset cloth color. Takes optional int layer number to reset only specific layer color."""
|
|
if n:
|
|
self.color[n] = [x for x in self.color_default]
|
|
else:
|
|
self.color = [x[:] for x in self.color_default]
|
|
self.is_stale()
|
|
|
|
def clone(self):
|
|
"""Creates a clone of this cloth object. Since it requires a parent object it should be used internally only to avoid object depth issue."""
|
|
if self.parent:
|
|
return self
|
|
return DollCloth(self.name, self.categories, self.type, self.id, [x[:] for x in self.color] if self.color else None, self.zorder, self.unlocked, self.level, self.blacklist, self.modpath, self)
|
|
|
|
def is_modded(self):
|
|
"""Returns True if item comes from a mod."""
|
|
return bool(self.modpath)
|
|
|
|
def get_modname(self):
|
|
"""Return the name of the mod directory if exists."""
|
|
return self.modpath.split("/")[1] if self.is_modded() else None
|
|
|
|
def mark_as_seen(self):
|
|
self.seen = True
|
|
|
|
def is_multislot(self):
|
|
return self.type in self.multislots
|
|
|
|
def unlock(self):
|
|
self.unlocked = True
|
|
|
|
if self.parent:
|
|
self.parent.unlock()
|