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, } _loading = Fixed(Text("Loading", align=(0.5, 0.5)), xysize=(96, 96)) __slots__ = ("name", "categories", "type", "id", "color", "unlocked", "level", "blacklist", "modpath", "parent", "char", "color_default", "zorder", "seen", "_hash") 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( (tuple(x) if isinstance(x, list) else x) ) for x in color] if color else None self.unlocked = unlocked self.level = level self.blacklist = blacklist self.modpath = "mods/" + posixpath.normpath(modpath) if modpath else "" self.parent = parent self.char = eval(name) self.color_default = [x for x in self.color] if self.color else None self.zorder = zorder or self.char.states[type][1] self.seen = self.unlocked self._hash = self.generate_hash() self._button = DefaultQueue() if config.developer: self._loading = Fixed(Text(self.id, size=8), self._loading, xysize=(96, 96)) # 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 __lt__(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 hashstring(salt) @functools.cache # <- TODO: Reevaluate if this function should allow multiple caches (aside from different subpaths) def get_layers(self, hash, subpath=""): path = posixpath.join(self.modpath, "characters", self.name, "poses", self.char.pose, subpath, "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(f"Invalid layer type for file: {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(f"Invalid modifier for file: {f}") continue zorder_mod = modifiers.get(lmodifier) zorder = (zorder + int(zorder_mod)) if lmodifier != "zorder" else int(tails[-1]) layers.setdefault("_".join([ltype, lmodifier, str(zorder)]), [f, zorder]) else: layers.setdefault(ltype, [f, zorder]) return dict(sorted(layers.items(), key=lambda x: (extract_number(x[0]), x[0]))) @functools.cache def build_image(self, hash, subpath="", matrix=None, maxsize=(1010, 1200)): # Note to self: This function is called from within the new chibi class during clothes iteration, # as a bridge to enable colourable clothing for chibis, double-check the changes before submitting them. if matrix is None: matrix = self.char.body.matrix processors = { "skin": lambda file, _: Transform(file, maxsize=maxsize, matrixcolor=matrix), "armfix": lambda file, _: Transform(file, maxsize=maxsize, matrixcolor=matrix), "colored": lambda file, n: self.apply_color(file, int(n), maxsize), "default": lambda file, _: Transform(file, maxsize=maxsize), } layers = self.get_layers(hash, subpath) sprites = [] for identifier, (file, zorder) in layers.items(): if ((n := identifier.split("_", 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): if (d := self.get_disk_cache(hash)): return d 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: print(f"Missing textures:\n{self.__repr__()}") return Text(f"TexErr\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 = 256 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 d = Transform(Fixed(*[i[1] for i in sprites], fit_first=True), crop=(x, y, w, h), size=(256, 256), fit="contain", align=(0.5, 0.5)) return self.create_disk_cache(d, hash) @property def icon(self): return self.build_icon(self._hash) def _build_button(self, _hash): style = "wardrobe_item_button" is_seen = self.seen is_equipped = self.char.is_equipped_item(self) is_inadequate = bool(get_character_progression(self.char.name) < self.level) is_blacklisted = self.char.is_blacklisted(self.type) is_blacklister = any(self.char.is_equipped(x) for x in self.blacklist) is_modded = bool(self.modpath) warnings = [] if is_blacklisted or is_blacklister: blacklisted = [x for x in self.blacklist if self.char.is_equipped(x)] # Offender (List currently blacklisted clothing types by this item) blacklister = self.char.get_blacklister(self.type) # Victim (List clothing types blacklisting this item ) warnings.append("Incompatible with:{size=-4}\n" + "\n".join(set(blacklisted + blacklister)) + "{/size}") child = AlphaMask(Transform(self.icon, xysize=(96, 96)), Transform("wheelmenu_button_opaque", xysize=(96, 96))) hbox = [] overlay = [] action = [Function(wardrobe.equip, self), self.build_button] unhovered = None if is_inadequate: warnings.append("Character level too low") if is_modded: warnings.append("Item belongs to a mod:\n{size=-4}{color=#35aae2}" + self.get_modname() + "{/color}{/size}") hbox.append(Text("M", color="#00b200", style="wardrobe_button_text")) if is_blacklisted or is_blacklister: hbox.append(Text("!", color="#b20000", style="wardrobe_button_text")) for i in self.char.wardrobe_list: if i.unlocked and i.type == self.type: action.append(i.build_button) if config.developer and self.level > 0: hbox.append(Text(str(self.level), color="#00ffff", style="wardrobe_button_text")) if not is_seen: unhovered = [Function(self.mark_as_seen), self.clear_button_cache, self.build_button] overlay.append(Text("NEW", align=(1.0, 1.0), offset=(-6, -6), style="wardrobe_button_text")) # if is_equipped: # overlay.append(Transform("interface/topbar/icon_check.webp", align=(1.0, 1.0), offset=(-6, -6), size=(24, 24))) if hbox: overlay.append(HBox(*hbox, offset=(6, 6))) if overlay: child = Fixed(child, *overlay, fit_first=True) if is_inadequate: style = "wardrobe_item_button_inadequate" return Button(child=child, focus_mask=None, xysize=(96, 96), action=action, tooltip=("\n".join(warnings)), unhovered=unhovered, style=style, selected=is_equipped) @functools.cache def build_button(self, _=None): def _func(self, hash): result = self._build_button(self._hash) self._button.put(result) thread = DollThread(target=_func, args=(self, self._hash)) thread.start() @property def button(self): if settings.get("multithreading"): return self._button.get_with_default(self._loading) else: return self._build_button(self._hash) def clear_button_cache(self): if not settings.get("multithreading"): return self.build_button.cache_clear() def apply_color(self, img, n, maxsize): """Takes image and int layer number. Used internally.""" try: c = self.color[n] # Method 1 # r,g,b = c.rgb # average = max(min((r + g + b) / 3, 0.6666), 0.3333) # Method 2 # lightness = max(min(c.hls[1], 0.6666), 0.3333) # average = (lightness, lightness, lightness) # Method 3 # r,g,b = c.rgb # luster = (max(r,g,b)+min(r,g,b)) / 2 # c = c.replace_lightness(luster) # average = (luster, luster, luster) # Method 4 average = (0.3333, 0.3333, 0.3333) return Transform(img, maxsize=maxsize, matrixcolor=SepiaMatrix(c, desat=average)*OpacityMatrix(c.alpha)) except TypeError: print(f"Item doesn't support colors but was supplied with a list; Try removing numbered files in its directory:\n{self.__repr__()}") d = At(Frame(Text("TypeErr", color="#ffff00"), tile=True), blink_repeat) return Transform(AlphaMask(d, img)) except IndexError: print(f"Mismatched number of textures and colors; Try reducing number of supplied colours in item definition:\n{self.__repr__()}") d = At(Frame(Text("IndxErr", color="#ff0000"), tile=True), blink_repeat) return Transform(AlphaMask(d, img)) 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 = self.color[n] dcol = self.color_default[n] cp.live_replace(col) cp.start_replace(col) cp.default_replace(dcol) renpy.show_screen("colorpickerscreen", self) while True: try: action, value = ui.interact() except: print(f"{ui.interact()}") break if action == "layer": n = value col = self.color[value] dcol = self.color_default[value] cp.live_replace(col) cp.start_replace(col) cp.default_replace(dcol) elif action == "released": self.color[n] = value self.is_stale() elif action == "replace": self.color[n] = value cp.live_replace(value) self.is_stale() elif action == "finish": break renpy.hide_screen("colorpickerscreen") elif isinstance(n, list): self.color = [Color( (tuple(x) if isinstance(x, list) else 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] = self.color_default[n] 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.""" modpath = self.modpath.lstrip("mods/") 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, 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()