Skip to content
Snippets Groups Projects
skin.js 14 KiB
Newer Older
  • Learn to ignore specific revisions
  • xao's avatar
    xao committed
    /* Move to player class later */
    
    
    KnotLikeThis's avatar
    KnotLikeThis committed
    const Sunscreen = (() => {
    	function getDuration() {
    
    Purity's avatar
    Purity committed
    		return TimeConstants.secondsPerDay;
    
    KnotLikeThis's avatar
    KnotLikeThis committed
    	}
    
    	function apply() {
    		const { sunscreen } = V.player.skin;
    		if (sunscreen.usesLeft <= 0) return;
    		sunscreen.lastsUntil = V.timeStamp + getDuration();
    		sunscreen.usesLeft = Math.max(0, sunscreen.usesLeft - 1);
    	}
    
    	function isApplied() {
    		const { sunscreen } = V.player.skin;
    		if (sunscreen.lastsUntil && V.timeStamp < sunscreen.lastsUntil) return true;
    		delete sunscreen.lastsUntil;
    		return false;
    	}
    
    	return {
    		/** Total duration of one use of sunscreen, in seconds */
    		get duration() {
    			return getDuration();
    		},
    		get bottle() {
    			return {
    				price: 1500,
    				uses: 15,
    			};
    		},
    		apply,
    		remove() {
    			delete V.player.skin.sunscreen.lastsUntil;
    		},
    		isApplied,
    		get timeLeft() {
    			const { lastsUntil } = V.player.skin.sunscreen;
    			return lastsUntil ? Math.max(0, lastsUntil - V.timeStamp) : 0;
    		},
    		/** @returns {number} */
    		get usesLeft() {
    			return V.player.skin.sunscreen.usesLeft;
    		},
    		/** @param {number} [uses] */
    		addUses(uses) {
    			V.player.skin.sunscreen.usesLeft += uses ?? this.bottle.uses;
    		},
    	};
    })();
    
    
    xao's avatar
    xao committed
    /*
    	Skin.tanningBonus: Value between 0 and 1. The bonus exists until time has been passed.
    */
    const Skin = (() => {
    
    Purity's avatar
    Purity committed
    	// Constants
    
    xao's avatar
    xao committed
    	const defaultModel = ["main", "sidebar"];
    	const defaultLayer = { layers: [], slots: {} };
    	const tanningMultiplier = 9; // Increase to make the tanning function even out more sharply (as the tan level increases)
    	const scalingFactor = 0.033; // Decrease for slower tanning gain from sun intensity
    	const tanningLossPerMinute = 0.000695; // ~1 per day - ~100 days from 100% to 0%
    
    xao's avatar
    xao committed
    	const maxLayerGroups = 6;
    
    xao's avatar
    xao committed
    
    
    Purity's avatar
    Purity committed
    	// Properties
    	const cachedLayers = null;
    	let accumulatedValue = 0;
    
    
    xao's avatar
    xao committed
    	/**
    	 * Only run this from time.js
    	 *
    	 * TANNING GAIN/DECAY:
    	 * - Logarithmic gain: Tanning gain slows down the higher it is.
    	 * - If the total tanning value exceeds 100, the gain will be capped at 100, and any excess will be treated as tanning loss (to all groups except the one that gets the tanning gain)
    	 * - Tanning decay is linear over time.
    	 * - If a group gains tanning during the same time - only that group won't lose tanning.
    	 * - If a group loses tanning to below 0, that group will be removed.
    	 * - If tanning loss is higher than a group's value (causing it to be removed), the remainder will be distributed as a loss to the other layers.
    	 * - If more than 60 minutes pass at once - divide the tanning calculations into 60-minute chunks.
    	 *   This is to follow the logarithmic curve more closely, and more realistically follow the day/night sun intensity cycle.
    	 * - Limit of 10 layer groups. If 10 groups exist, and we want to add another one, remove the group with the lowest value, and distribute its value to the remaining groups.
    	 *
    	 * RENDERING:
    	 * - Canvas needs to be rendered once in order for the layers to be saved for the tanning masks. (see limitations below)
    	 * - Layer groups are created after the canvas has been readied for renderering (but before its rendered), in a new postprocess function.
    	 * - Each layer group consist of the clothes being worn at the time of tanning - minus head, and handheld.
    	 * - Layer groups are converted to their own canvas masks.
    	 * - Animations are removed for all layers except for arms, and upper. (to avoid moving tan-lines)
    	 * - Canvas masks are cached to avoid reloading them unecessarily.
    	 * - They are re-cached only if the src layer has been changed. (E.g. if hand position goes from idle to cover)
    	 * - If no clothes are applied when tanning - an empty layer group (with an empty mask) will be applied instead - causing the whole body to get tanned.
    	 * - If there are multiple layer groups - apply the one with the highest value first. The remaining layers are applied on top, with their respective alpha value.
    	 *
    	 * @param {number} minutes
    	 */
    	function applyTanningGain(minutes) {
    		// Use event to wait for renderer before applying gain
    		$(document).one(":passageend", () => {
    			const model = Renderer.locateModel(...defaultModel);
    			const savedLayers = V.player.skin.layers;
    			const nextTime = new DateTime(Time.date);
    
    xao's avatar
    xao committed
    
    			if (!model.tanningLayers?.layers) {
    				console.warn("applyTanningGain: CanvasModel not found.");
    				return;
    			}
    
    			// If more than 60 minutes have passed, process tanning in chunks
    			while (minutes > 0) {
    				const chunkMinutes = Math.min(60, minutes);
    				minutes -= chunkMinutes;
    
    				const gainAmount = chunkMinutes * getTanningFactor(Skin.tanningBed).result;
    				if (gainAmount === 0) continue;
    
    				const currentTan = getTanningValue(savedLayers);
    				const current = getCurrentLayers(model, savedLayers);
    				const selectedLayers = setLayers(savedLayers, current);
    
    				selectedLayersIndex = current.index;
    
    xao's avatar
    xao committed
    
    
    xao's avatar
    xao committed
    				const logFactorGain = 1 / Math.log1p(((currentTan + accumulatedValue) / 100) * tanningMultiplier + 1);
    
    xao's avatar
    xao committed
    				let tanningGain = gainAmount * logFactorGain * scalingFactor;
    
    				// Handle tanning gain and ensure the total tanning value does not exceed 100
    				if (currentTan + tanningGain >= 100) {
    					lowerTanningInLayers(savedLayers, currentTan + tanningGain - 100, savedLayers.indexOf(selectedLayers));
    					tanningGain = 100 - currentTan;
    				}
    
    				// Apply tanning gain if there's any
    				if (tanningGain > 0) {
    					selectedLayers.value += tanningGain;
    
    Purity's avatar
    Purity committed
    					accumulatedValue += tanningGain;
    
    xao's avatar
    xao committed
    				}
    
    				nextTime.addMinutes(chunkMinutes);
    			}
    
    			// Distribute lowest if layers become more than maxLayerGroups
    			if (savedLayers.length > maxLayerGroups) {
    
    				const lowestValueGroup = savedLayers
    					.filter((_, index) => index !== selectedLayersIndex) // Exclude the selected layer
    					.reduce((min, group) => (group.value < min.value ? group : min)); // Find the group with the lowest value
    
    xao's avatar
    xao committed
    				const index = savedLayers.indexOf(lowestValueGroup);
    				if (index !== -1) {
    					const [removedGroup] = savedLayers.splice(index, 1);
    					const valueToDistribute = removedGroup.value / savedLayers.length;
    					savedLayers.forEach(group => (group.value += valueToDistribute));
    				}
    			}
    
    			// Reset bonus and other modifiers after time passes
    			Skin.tanningBonus = 0;
    			Skin.tanningBed = false;
    
    Purity's avatar
    Purity committed
    
    			if (accumulatedValue > 0.1) {
    				Skin.recache();
    			}
    
    xao's avatar
    xao committed
    		});
    	}
    
    	function applyTanningLoss(minutes) {
    		const savedLayers = V.player.skin.layers;
    		const totalTanningLoss = minutes * tanningLossPerMinute;
    
    		// Reduce tanning on all layers equally
    		if (savedLayers.length > 0) {
    			lowerTanningInLayers(savedLayers, totalTanningLoss);
    		}
    
    Purity's avatar
    Purity committed
    		if (totalTanningLoss > 0.1) Skin.recache();
    
    xao's avatar
    xao committed
    	}
    
    	function lowerTanningInLayers(groups, totalTanningLoss, skipIndex = -1) {
    		const totalValue = groups.reduce((sum, group, index) => sum + (index !== skipIndex ? group.value : 0), 0);
    
    		// If reduction is to 0 or below, remove all layers, unless skipped
    		if (totalTanningLoss >= totalValue) {
    			groups.splice(0, groups.length, ...(skipIndex !== -1 ? [groups[skipIndex]] : []));
    			return;
    		}
    
    		let remainingLoss = totalTanningLoss;
    		let totalDistributedLoss = 0;
    		// Distribute the loss among the layers until the remaining loss is zero
    		// Adjust for floating point errors
    		while (round(remainingLoss, 8) > 0) {
    			let layerCount = 0;
    			const lossPerLayer = remainingLoss / (groups.length - (skipIndex >= 0 ? 1 : 0));
    			for (let i = groups.length - 1; i >= 0; i--) {
    				const group = groups[i];
    
    				if (groups.indexOf(group) === skipIndex) continue;
    
    				if (group.value > 0) {
    					layerCount++;
    					const actualLoss = Math.min(lossPerLayer, group.value);
    					const roundedLoss = round(group.value - actualLoss, 8);
    					remainingLoss -= actualLoss;
    					totalDistributedLoss += group.value - roundedLoss;
    					group.value = roundedLoss;
    				}
    
    				// Remove group if it drops to 0
    				if (group.value <= 0) {
    					remainingLoss += -group.value;
    					groups.splice(i, 1);
    				} else {
    					group.value = round(group.value, 8);
    				}
    			}
    			if (layerCount === 0) {
    				break;
    			}
    		}
    		// Distribute the remaining value from rounding into the first element
    		const adjustment = round(totalTanningLoss - totalDistributedLoss, 8);
    		if (groups.length > 0) {
    			groups[0].value = round(groups[0].value + adjustment, 8);
    		}
    	}
    
    	function getCurrentLayers(model, savedLayers) {
    		const index = tryGetMatchingLayer(savedLayers, model.tanningLayers);
    		if (index !== null) {
    			return { index, layers: savedLayers[index].layers, slots: savedLayers[index].slots };
    		}
    		return { index: null, layers: model.tanningLayers.layers, slots: model.tanningLayers.slots };
    	}
    
    	function tryGetMatchingLayer(groups, targetLayer) {
    		for (let i = 0; i < groups.length; i++) {
    			if (groups[i].slots.isEqual(targetLayer.slots)) {
    				return i;
    			}
    		}
    		return null;
    	}
    
    
    Jimmys's avatar
    Jimmys committed
    	/**
    	 * @param {any[]} groups
    	 * @returns {number}
    	 */
    
    xao's avatar
    xao committed
    	function getTanningValue(groups) {
    		return groups.reduce((sum, obj) => sum + (obj.value ?? 0), 0);
    	}
    
    	function setLayers(savedLayers, currentLayers, index) {
    
    Purity's avatar
    Purity committed
    		index ??= currentLayers.index;
    
    xao's avatar
    xao committed
    		if (!currentLayers) {
    			console.warn("setTanning: Could not find clothing groups");
    			return null;
    		}
    		if (index == null) {
    			savedLayers.push({
    				layers: currentLayers.layers,
    				slots: currentLayers.slots,
    				value: 0,
    			});
    			index = savedLayers.length - 1;
    		}
    		return savedLayers[index];
    	}
    
    	/**
    	 * Returns tanning factor based on:
    	 * sunIntensity (intensity from month of the year)
    	 * weatherModifier (based on weather)
    	 * locationModifier (based on location)
    	 * clothingModifier (based on clothing)
    
    KnotLikeThis's avatar
    KnotLikeThis committed
    	 * sunscreenModifier (based on used sunscreen)
    
    xao's avatar
    xao committed
    	 * dayFactor (based on sun position in the sky) - always 0 at night
    	 *
    	 * @param {boolean} ignoreOutside Forces outside check
    	 */
    	function getTanningFactor(ignoreOutside) {
    		const outside = ignoreOutside ? 0 : V.outside;
    		const sunIntensity = (ignoreOutside ? 1 : Weather.getSunIntensity()) * (1 + Skin.tanningBonus);
    		// Reduces tanning effect even with only 1 shading clothing item
    
    KnotLikeThis's avatar
    KnotLikeThis committed
    		const clothingModifier = Object.values(V.worn).some(item => item.type.includes("shade")) ? 0.1 : 1;
    		// sunscreen prevents tanning gains entirely
    		const sunscreenModifier = Skin.Sunscreen.isApplied() ? 0 : 1;
    
    xao's avatar
    xao committed
    		// Halved gain if gyaru
    
    xao's avatar
    xao committed
    		const skinType = ["gyaru", "ygyaru"].includes(Skin.color.natural) ? 0.5 : 1;
    
    xao's avatar
    xao committed
    
    
    xao's avatar
    xao committed
    		const result = round(sunIntensity * clothingModifier * sunscreenModifier * skinType, 2);
    
    xao's avatar
    xao committed
    		return {
    			sun: sunIntensity,
    			month: Weather.genSettings.months[Time.date.month - 1].sunIntensity,
    			weather: outside ? Weather.current.tanningModifier : 1,
    			location: V.location === "forest" ? 0.2 : 1,
    			dayFactor: outside ? Time.date.simplifiedDayFactor : 1,
    			clothing: clothingModifier,
    
    KnotLikeThis's avatar
    KnotLikeThis committed
    			sunscreen: sunscreenModifier,
    
    xao's avatar
    xao committed
    			type: skinType,
    
    xao's avatar
    xao committed
    			result,
    		};
    	}
    
    	function tanningGainOutput(modifier, minutes) {
    		if (V.statdisable !== "f") return "";
    		const factor = modifier * minutes;
    		if (factor === 0) {
    
    KnotLikeThis's avatar
    KnotLikeThis committed
    			return statDisplay.statChange("No tanning effect", 0, "blue");
    
    xao's avatar
    xao committed
    		}
    		return statDisplay.statChange("Tan", factor >= 50 ? 3 : factor >= 20 ? 2 : 1, "green");
    	}
    
    	function tanningPenaltiesOutput(modifiers) {
    		const reasons = [];
    
    
    KnotLikeThis's avatar
    KnotLikeThis committed
    		if (modifiers.sunscreen === 0) {
    			return `<span class="blue">Sunscreen prevented you from tanning.</span><br>`;
    		}
    
    
    xao's avatar
    xao committed
    		if (V.outside) {
    			const month = modifiers.month <= 0.6;
    			const dayState = Weather.sky.dayFactor <= 0.6;
    			const output = month ? Time.monthName : dayState ? "Sun is low" : "weather";
    			if (modifiers.sun <= 0.3) reasons.push(`Low sun intensity (${output})`);
    			else if (modifiers.sun <= 0.7) reasons.push(`Reduced sun intensity (${output})`);
    
    			if (modifiers.weather < 1) reasons.push("Light clouds");
    		}
    		if (modifiers.clothing < 1) reasons.push("Shaded by clothing");
    
    		if (reasons.length === 0) return "";
    		return `<span class="teal">Your tanning gain was reduced due to:</span><br><span class="orange">${reasons.join("<br>")}</span><br>`;
    	}
    	return {
    
    Purity's avatar
    Purity committed
    		cachedLayers,
    
    xao's avatar
    xao committed
    		applyTanningGain,
    		applyTanningLoss,
    		getTanningFactor,
    		tanningGainOutput,
    		tanningPenaltiesOutput,
    		get tanningLayers() {
    			return V.player.skin.layers;
    		},
    		get tanningBonus() {
    			return V.player.skin.tanningBonus ?? 0;
    		},
    		set tanningBonus(value) {
    			V.player.skin.tanningBonus = Math.clamp(value, 0, 1);
    		},
    		get tanningBed() {
    			return V.player.skin.tanningBed ?? false;
    		},
    		set tanningBed(value) {
    			V.player.skin.tanningBed = !!value;
    		},
    		get totalTan() {
    			const layers = V.player.skin.layers;
    			return getTanningValue(layers);
    		},
    		get color() {
    			return {
    				get natural() {
    					return V.player.skin.color || "light";
    				},
    				set natural(value) {
    					V.player.skin.color = value;
    				},
    				get tan() {
    					const layers = V.player.skin.layers;
    					return getTanningValue(layers);
    				},
    				set tan(value) {
    					Skin.color.setTan(value);
    				},
    				setTan(value, wholeBody = true) {
    
    Purity's avatar
    Purity committed
    					Skin.recache();
    
    xao's avatar
    xao committed
    					value = Math.clamp(value, 0, 100);
    					const savedLayers = V.player.skin.layers;
    					const totalTan = getTanningValue(savedLayers);
    
    					if (totalTan === value) return;
    					if (totalTan > value) {
    						const tanningLoss = totalTan - value;
    						lowerTanningInLayers(savedLayers, tanningLoss);
    					} else {
    						let group = {};
    						if (wholeBody) {
    							group = defaultLayer;
    						} else {
    							const model = Renderer.locateModel(...defaultModel);
    							group = getCurrentLayers(model, savedLayers);
    						}
    						const tanningGain = value - totalTan;
    						const selectedLayer = setLayers(savedLayers, group, tryGetMatchingLayer(savedLayers, group));
    						selectedLayer.value += tanningGain;
    					}
    				},
    			};
    		},
    
    Purity's avatar
    Purity committed
    		recache() {
    			Skin.cachedLayers = null;
    			accumulatedValue = 0;
    		},
    
    xao's avatar
    xao committed
    		getImageCount() {
    
    KnotLikeThis's avatar
    KnotLikeThis committed
    			// return V.player.skin.layers.reduce((count, layerGroup) => count + layerGroup.groups.length, 0);
    
    xao's avatar
    xao committed
    		},
    		// todo Only for red images. Remove after combat rework
    		cssColorFilter(type) {
    			return setup.colours.getSkinCSSFilter(type ?? Skin.color.natural, Skin.totalTan);
    		},
    
    KnotLikeThis's avatar
    KnotLikeThis committed
    		Sunscreen,
    
    xao's avatar
    xao committed
    	};
    })();
    window.Skin = Skin;
    
    DefineMacro("tanningGainOutput", function () {
    	this.output.append(Skin.tanningGainOutput(...this.args));
    });
    
    DefineMacroS("tanningPenaltiesOutput", Skin.tanningPenaltiesOutput);