celeste-avali-skin/SourceCode/AvaliSkinModule.cs

558 lines
24 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Monocle;
using MonoMod.Cil;
using MonoMod.Utils;
using MonoMod.RuntimeDetour;
using Celeste.Mod.CelesteNet.Client;
using Celeste.Mod.CelesteNet.Client.Entities;
using Celeste.Mod.CelesteNet.DataTypes;
using ColorChoice = Celeste.Mod.AvaliSkin.AvaliSkinSettings.ColorChoice;
using DashColorMode = Celeste.Mod.AvaliSkin.AvaliSkinSettings.DashColorMode;
using ColorMode = Celeste.Mod.AvaliSkin.AvaliSkinSettings.ColorMode;
namespace Celeste.Mod.AvaliSkin {
// This code was built off of max480's code for the Pro Banana Skin.
// Other mods I referenced include SkinModHelper, Hyperline, Styleline, and Kayden Fox skin.
public class AvaliSkinModule : EverestModule {
public static AvaliSkinModule Instance;
public override Type SettingsType => typeof(AvaliSkinSettings);
public static AvaliSkinSettings Settings => (AvaliSkinSettings) Instance._Settings;
public static AvaliConfig PlayerConfig {
get => new AvaliConfig {
Enabled = Settings.Enabled,
DashColorMode = Settings.DashColorModeOpt,
DashColors =
Settings.DashColorModeOpt == DashColorMode.ManualPreset
? Settings.DashPreset.Select(preset => ColorUtil.SettingToColor(preset)).ToList()
: Settings.DashRGBColor,
LightBody = Settings.BodyColorModeOpt == ColorMode.ManualPreset ? ColorUtil.SettingToColor(Settings.LightBodyPreset) : Settings.LightBodyRGBColor,
DarkBody = Settings.BodyColorModeOpt == ColorMode.ManualPreset ? ColorUtil.SettingToColor(Settings.DarkBodyPreset) : Settings.DarkBodyRGBColor,
};
}
public static AvaliConfig EveryoneHasSkinConfig {
get => new AvaliConfig {
Enabled = true,
DashColorMode = DashColorMode.ExternalDash,
LightBody = PlayerConfig.LightBody,
DarkBody = PlayerConfig.DarkBody,
};
}
private static Effect FxRecolor;
public static EverestModuleMetadata CelesteNetMeta = new EverestModuleMetadata() {
Name = "CelesteNet.Client",
Version = new Version(2, 0, 0)
};
public AvaliSkinModule() => Instance = this;
public override void Load() {
Logger.Log(LogLevel.Info, "AvaliSkin", $"Hooking stuff...");
On.Celeste.LevelLoader.ctor += onLevelLoaderctor;
On.Celeste.Player.Render += onPlayerRender;
if (Everest.Loader.DependencyLoaded(CelesteNetMeta)) {
Logger.Log(LogLevel.Info, "AvaliSkin", $"Hooking for CelesteNet...");
On.Celeste.PlayerSprite.Render += onPlayerSpriteRenderCelestenet;
On.Celeste.PlayerSprite.Render += onPlayerSpriteRenderCelestenetMisc;
} else {
using (new DetourContext("AvaliSkinModule") {
After = { "Hyperline" }
}) {
On.Celeste.PlayerSprite.Render += onPlayerSpriteRenderMisc;
}
}
using (new DetourContext("AvaliSkinModule") {
After = { "Hyperline" }
}) {
On.Celeste.Player.GetCurrentTrailColor += onPlayerGetTrailColor;
On.Celeste.Player.UpdateHair += onPlayerUpdateHair;
On.Celeste.Player.DashUpdate += onPlayerDashUpdate;
}
using (new DetourContext("AvaliSkinModule") {
After = { "FastPlayerDie" }
}) {
On.Celeste.PlayerDeadBody.Render += onPlayerDeadBodyRender;
}
IL.Celeste.DeathEffect.Draw += DeathEffectDrawHook;
On.Celeste.Payphone.ctor += onPayphoneConstructor;
On.Celeste.Lookout.ctor += onLookoutConstructor;
}
public override void LoadContent(bool firstLoad) {
base.LoadContent(firstLoad);
IGraphicsDeviceService graphicsDeviceService =
Engine.Instance.Content.ServiceProvider
.GetService(typeof(IGraphicsDeviceService))
as IGraphicsDeviceService;
ModAsset asset = Everest.Content.Get("Effects/AvaliRecolor.o", true);
FxRecolor = new Effect(graphicsDeviceService.GraphicsDevice, asset.Data);
}
public override void Unload() {
Logger.Log(LogLevel.Info, "AvaliSkin", $"Unhooking stuff...");
On.Celeste.LevelLoader.ctor -= onLevelLoaderctor;
On.Celeste.Player.Render -= onPlayerRender;
if (Everest.Loader.DependencyLoaded(CelesteNetMeta)) {
Logger.Log(LogLevel.Info, "AvaliSkin", $"Unhooking hooks for CelesteNet...");
On.Celeste.PlayerSprite.Render -= onPlayerSpriteRenderCelestenet;
On.Celeste.PlayerSprite.Render -= onPlayerSpriteRenderCelestenetMisc;
} else {
On.Celeste.PlayerSprite.Render -= onPlayerSpriteRenderMisc;
}
using (new DetourContext("AvaliSkinModule") {
After = { "Hyperline" }
}) {
On.Celeste.Player.GetCurrentTrailColor -= onPlayerGetTrailColor;
On.Celeste.Player.UpdateHair -= onPlayerUpdateHair;
On.Celeste.Player.DashUpdate -= onPlayerDashUpdate;
}
using (new DetourContext("AvaliSkinModule") {
After = { "FastPlayerDie" }
}) {
On.Celeste.PlayerDeadBody.Render -= onPlayerDeadBodyRender;
}
IL.Celeste.DeathEffect.Draw -= DeathEffectDrawHook;
On.Celeste.Payphone.ctor -= onPayphoneConstructor;
On.Celeste.Lookout.ctor -= onLookoutConstructor;
FxRecolor.Dispose();
}
// Checks if the enabled status of the sprite needs to be updated, and if
// so, then swaps out the sprite at runtime with the Avali spritebank.
private void trySpriteSwap(PlayerSprite sprite, bool enabled) {
DynamicData dd = DynamicData.For(sprite);
bool oldEnabled = false;
// TryGet crashes with value types (of course; obviously!)
// So we use a boxed type with the nullable bool... this is a bug in monomod?
if (dd.TryGet<bool?>("avaliskin_enabled", out bool? ddoldEnabled)) {
oldEnabled = (bool) ddoldEnabled;
}
if (oldEnabled != enabled) {
string spriteID = "";
switch (sprite.Mode) {
case PlayerSpriteMode.Madeline:
spriteID = enabled ? "player_avali" : "player"; break;
case PlayerSpriteMode.MadelineNoBackpack:
spriteID = enabled ? "player_avali_no_backpack" : "player_no_backpack"; break;
case PlayerSpriteMode.Playback:
spriteID = enabled ? "player_avali_playback" : "player_playback"; break;
default: return;
}
dd.Set("avaliskin_enabled", (bool?) enabled);
// CreateOn doesn't preserve some of the properties that we need to keep here!
// Copy them over manually instead...
Vector2 pos = sprite.Position;
Color color = sprite.Color;
string currentAnimationID = sprite.CurrentAnimationID;
int currentAnimationFrame = sprite.CurrentAnimationFrame;
GFX.SpriteBank.CreateOn(sprite, spriteID);
sprite.Position = pos;
sprite.SetColor(color);
if (currentAnimationID != "") {
sprite.Play(currentAnimationID);
sprite.SetAnimationFrame(currentAnimationFrame);
}
}
}
private void spriteRecolor(Color dashColor, Color? lightColor = null, Color? darkColor = null) {
Color lightColor2 = lightColor != null
? (Color) lightColor : ColorUtil.HexToColor("a2885c");
Color darkColor2 = darkColor != null
? (Color) darkColor : ColorUtil.HexToColor("4e4e4e");
// apply the recolor effect to the player:
// replace the color #1ad589 in the sprite with color
FxRecolor.Parameters["recolor1_threshold"].SetValue(0.03f);
FxRecolor.Parameters["recolor1_rgb_from"].SetValue(
(new Color((byte) 0x1a, 0xd5, 0x89, 0xff)).ToVector4()
);
FxRecolor.Parameters["recolor1_rgb_to"].SetValue(dashColor.ToVector4());
// works at 0.12 minus the dark brown (actually works for real)
FxRecolor.Parameters["rehue1_threshold"].SetValue(0.12f);
FxRecolor.Parameters["rehue1_threshold_mul"].SetValue(
new Vector4(1f, 0.1f, 0.3f, 0f)
);
FxRecolor.Parameters["rehue1_hsv_from"].SetValue(
(new Color((byte) 0xa2, 0x88, 0x5c, 0xff)).ToHSV() // #a2885c
);
FxRecolor.Parameters["rehue1_hsv_to"].SetValue(
lightColor2.ToHSV()
);
FxRecolor.Parameters["rehue2_threshold"].SetValue(0.07f);
FxRecolor.Parameters["rehue2_threshold_mul"].SetValue(
new Vector4(1f, .8f, .8f, 0f)
);
FxRecolor.Parameters["rehue2_hsv_from"].SetValue(
(new Color((byte) 0x4e, 0x4e, 0x4e, 0xff)).ToHSV() // #4e4e4e
);
FxRecolor.Parameters["rehue2_hsv_to"].SetValue(
darkColor2.ToHSV()
);
Viewport viewport = Engine.Graphics.GraphicsDevice.Viewport;
Camera camera = (Engine.Scene as Level).Camera;
Matrix projection = Matrix.CreateOrthographicOffCenter(
0, viewport.Width, viewport.Height, 0, 0, 1
);
FxRecolor.Parameters["TransformMatrix"].SetValue(projection);
FxRecolor.Parameters["ViewMatrix"].SetValue(camera?.Matrix ?? Matrix.Identity);
FxRecolor.CurrentTechnique = FxRecolor.Techniques["Recolor"];
}
private void onLevelLoaderctor(
On.Celeste.LevelLoader.orig_ctor orig, LevelLoader self,
Session session, Vector2? startPosition = null
) {
orig(self, session, startPosition);
// This only needs to be ran once, but we can't run this in LoadContent
// because the sprites are not loaded yet... see the original Everest
// source for this function
PlayerSprite.CreateFramesMetadata("player_avali");
PlayerSprite.CreateFramesMetadata("player_avali_no_backpack");
PlayerSprite.CreateFramesMetadata("player_avali_playback");
}
private void onPlayerRender(On.Celeste.Player.orig_Render orig, Player self) {
PlayerSprite sprite = self.Sprite;
if (sprite.Scene != null && PlayerConfig.IsEnabled(self)) {
// swap out the player's spritebank if the enabled state changed
trySpriteSwap(sprite, true);
Color color = PlayerConfig.GetColor(self);
// apply the recolor effect to the player
spriteRecolor(color, PlayerConfig.LightBody, PlayerConfig.DarkBody);
DynData<SpriteBatch> spriteData = new DynData<SpriteBatch>(Draw.SpriteBatch);
Matrix matrix = (Matrix)spriteData["transformMatrix"];
Draw.SpriteBatch.End();
Draw.SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointWrap, DepthStencilState.None, RasterizerState.CullNone, FxRecolor, matrix);
// render Avali...
orig(self);
// ... and reset rendering to stop using the effect
Draw.SpriteBatch.End();
Draw.SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointWrap, DepthStencilState.None, RasterizerState.CullNone, null, matrix);
} else if (self.Scene != null) {
trySpriteSwap(sprite, false);
orig(self);
} else {
orig(self);
}
}
private void onPlayerSpriteRenderMisc(On.Celeste.PlayerSprite.orig_Render orig, PlayerSprite self) {
Player player = Engine.Scene.Tracker.GetEntity<Player>();
if (player == null) {
orig(self);
return;
}
// This handles rendering of misc instances of PlayerSprites:
// usually player playback entities and the such.
if (
self.Scene != null
&& (
self.Entity == null ||
!(self.Entity is Player || self.Entity is PlayerDeadBody)
)
) {
if (PlayerConfig.IsEnabled(player)) {
trySpriteSwap(self, true);
Color color = PlayerConfig.GetColor(player);
// if (self.Entity is PlayerPlayback playback) {
// // handle someday soon...
// }
// apply the recolor effect to the sprite
spriteRecolor(color, PlayerConfig.LightBody, PlayerConfig.DarkBody);
DynData<SpriteBatch> spriteData = new DynData<SpriteBatch>(Draw.SpriteBatch);
Matrix matrix = (Matrix)spriteData["transformMatrix"];
Draw.SpriteBatch.End();
Draw.SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointWrap, DepthStencilState.None, RasterizerState.CullNone, FxRecolor, matrix);
// render the sprite...
orig(self);
// ... and reset rendering to stop using the effect
Draw.SpriteBatch.End();
Draw.SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointWrap, DepthStencilState.None, RasterizerState.CullNone, null, matrix);
} else {
trySpriteSwap(self, false);
orig(self);
}
} else {
orig(self);
}
}
private void onPlayerSpriteRenderCelestenetMisc(On.Celeste.PlayerSprite.orig_Render orig, PlayerSprite self) {
// This handles rendering of misc instances of PlayerSprites:
// usually player playback entities and the such.
if (
self.Scene != null && (
self.Entity == null
|| !(self.Entity is Player || self.Entity is PlayerDeadBody || self.Entity is Ghost)
)
) {
onPlayerSpriteRenderMisc(orig, self);
} else {
orig(self);
}
}
// CelesteNet must be loaded when calling this function.
private void onPlayerSpriteRenderCelestenet(On.Celeste.PlayerSprite.orig_Render orig, PlayerSprite self) {
// CelesteNet players are not actually Player classes, but instead this custom Ghost class.
// Ghosts have their own custom hair and sprite which we are able to recolor like the player.
// We need to be really paranoid here cuz celestenet jank...
CelesteNetClient client = CelesteNetClientModule.Instance.Client;
Ghost ghost;
AvaliConfig config;
if (
self.Scene != null && self.Entity != null
&& self.Entity is Ghost ghost2 && ghost2.PlayerInfo != null
&& client != null
&& client.Data.TryGetBoundRef<DataPlayerInfo, DataPlayerAvaliSkin>(
ghost2.PlayerInfo.ID,
out DataPlayerAvaliSkin data
) && data != null
&& data.Config.IsEnabled(ghost2)
) {
ghost = ghost2;
config = data.Config;
} else if (
Settings.CelesteNetEveryoneHasSkin
&& self.Scene != null && self.Entity != null
&& self.Entity is Ghost ghost3
&& EveryoneHasSkinConfig.IsEnabled(ghost3)
) {
ghost = ghost3;
config = EveryoneHasSkinConfig;
} else if (
self.Scene != null && self.Entity != null
&& self.Entity is Ghost
) {
trySpriteSwap(self, false);
orig(self);
return;
} else {
orig(self);
return;
}
// swap out the ghost's spritebank if the enabled state changed
trySpriteSwap(self, true);
// apply the recolor effect to the ghost
spriteRecolor(config.GetColor(ghost), config.LightBody, config.DarkBody);
DynData<SpriteBatch> spriteData = new DynData<SpriteBatch>(Draw.SpriteBatch);
Matrix matrix = (Matrix)spriteData["transformMatrix"];
Draw.SpriteBatch.End();
Draw.SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointWrap, DepthStencilState.None, RasterizerState.CullNone, FxRecolor, matrix);
// render the ghost...
orig(self);
// ... and reset rendering to stop using the effect
Draw.SpriteBatch.End();
Draw.SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointWrap, DepthStencilState.None, RasterizerState.CullNone, null, matrix);
}
public void onPlayerDeadBodyRender(On.Celeste.PlayerDeadBody.orig_Render orig, PlayerDeadBody self) {
DynamicData dd = DynamicData.For(self);
Player player = dd.Get<Player>("player");
PlayerSprite sprite = dd.Get<PlayerSprite>("sprite");
if (sprite.Scene != null && PlayerConfig.IsEnabled(player)) {
// swap out the body's spritebank if the enabled state changed
trySpriteSwap(sprite, true);
// apply the recolor effect to the body
spriteRecolor(
PlayerConfig.GetColor(player),
PlayerConfig.LightBody,
PlayerConfig.DarkBody
);
DynData<SpriteBatch> spriteData = new DynData<SpriteBatch>(Draw.SpriteBatch);
Matrix matrix = (Matrix)spriteData["transformMatrix"];
Draw.SpriteBatch.End();
Draw.SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointWrap, DepthStencilState.None, RasterizerState.CullNone, FxRecolor, matrix);
// render the dead Avali :(
orig(self);
// ... and reset rendering to stop using the effect
Draw.SpriteBatch.End();
Draw.SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointWrap, DepthStencilState.None, RasterizerState.CullNone, null, matrix);
} else if (self.Scene != null) {
trySpriteSwap(sprite, false);
orig(self);
} else {
orig(self);
}
}
private void DeathEffectDrawHook(ILContext il) {
// replace death particle, just like SkinModHelper
ILCursor cursor = new ILCursor(il);
while (cursor.TryGotoNext(MoveType.After, instr => instr.MatchLdstr("characters/player/hair00"))) {
cursor.EmitDelegate<Func<string, string>>(ReplaceDeathParticle);
}
}
private static string ReplaceDeathParticle(string deathParticle) {
if (PlayerConfig.Enabled) {
string newDeathParticle = "characters/Avali/death_particle";
return newDeathParticle;
}
return deathParticle;
}
private Color onPlayerGetTrailColor(On.Celeste.Player.orig_GetCurrentTrailColor orig, Player self) {
if (PlayerConfig.IsEnabled(self)) {
// Don't change the trail color if another mod is in control of it!
// The hair mod should do be doing that instead of us.
// Furthermore: naively doing this will look strange because the white
// hair flash right after dashing will be copied to the trail.
if (PlayerConfig.DashColorMode != DashColorMode.ExternalDash) {
// replace trail colors with marking colors
return PlayerConfig.GetColor(self).Premultiply();
}
}
// skin disabled, keep original colors
return orig(self);
}
private void onPlayerUpdateHair(On.Celeste.Player.orig_UpdateHair orig, Player self, bool applyGravity) {
orig(self, applyGravity);
// Don't change the hair color if another mod is in control of it!
if (PlayerConfig.IsEnabled(self) && PlayerConfig.DashColorMode != DashColorMode.ExternalDash) {
// change player hair color to match dash colors.
// (hair is invisible, but that influences other things like the orbs when the Avali dies and respawns)
self.Hair.Color = PlayerConfig.GetColor(self).Premultiply();
}
}
private int onPlayerDashUpdate(On.Celeste.Player.orig_DashUpdate orig, Player self) {
if (!(
PlayerConfig.IsEnabled(self)
// We can't exfiltrate a dash color in this mode because we can't extract that
// without integrating with the dash color mod, and besides, whatever mod that
// changed the dash color should have also changed these particles regardless.
&& PlayerConfig.DashColorMode != DashColorMode.ExternalDash
)) {
// disabled, just run vanilla code
return orig(self);
}
Color color = PlayerConfig.GetColor(self).Premultiply();
// back up vanilla particles
ParticleType bakDashA = Player.P_DashA;
ParticleType bakDashB = Player.P_DashB;
ParticleType bakDashBadB = Player.P_DashBadB;
// Replace them with recolored ones.
// We need to generate these dash particles on the fly because multiple players may
// have different colors (e.g. Celestenet).
Player.P_DashA = new ParticleType(Player.P_DashA) {
Color = color,
Color2 = color,
};
Player.P_DashB = new ParticleType(Player.P_DashA);
Player.P_DashBadB = new ParticleType(Player.P_DashA);
// run vanilla code: if it emits particles, it will use recolored ones.
int result = orig(self);
// restore vanilla particles
Player.P_DashA = bakDashA;
Player.P_DashB = bakDashB;
Player.P_DashBadB = bakDashBadB;
return result;
}
// todo: apply shader to these entities
private void onPayphoneConstructor(On.Celeste.Payphone.orig_ctor orig, Payphone self, Vector2 pos) {
orig(self, pos);
if (Settings.Enabled) {
// replace payphone sprites
self.Remove(self.Sprite);
self.Add(self.Sprite = GFX.SpriteBank.Create("payphone_avali"));
self.Sprite.Play("idle");
}
}
private void onLookoutConstructor(On.Celeste.Lookout.orig_ctor orig, Lookout self, EntityData data, Vector2 pos) {
orig(self, data, pos);
if (Settings.Enabled) {
// replace lookout (binoculars) sprites
DynamicData lookoutData = new DynamicData(self);
Sprite origSpr = lookoutData.Get<Sprite>("sprite");
GFX.SpriteBank.CreateOn(origSpr, "lookout_avali");
}
}
}
}