celeste-avali-skin/SourceCode/AvaliSkinModule.cs

430 lines
18 KiB
C#

using System;
using System.Collections.Generic;
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 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,
ColorMode = Settings.ColorModeOpt,
ManualPreset = Settings.DashPreset,
ManualRGB = Settings.DashRGBColor
};
}
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 {
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;
}
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;
}
IL.Celeste.DeathEffect.Draw -= DeathEffectDrawHook;
On.Celeste.Payphone.ctor -= onPayphoneConstructor;
On.Celeste.Lookout.ctor -= onLookoutConstructor;
FxRecolor.Dispose();
}
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 object 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);
Vector2 pos = sprite.RenderPosition;
GFX.SpriteBank.CreateOn(sprite, spriteID);
sprite.RenderPosition = pos;
}
}
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:
// replace the color #1ud589 in the sprite with color
FxRecolor.Parameters["threshold"].SetValue(0.01f);
FxRecolor.Parameters["color_replace_from"].SetValue(
(new Color((byte) 0x1u, 0xd5, 0x89, 0xff)).ToVector4()
);
FxRecolor.Parameters["color_replace_to"].SetValue(color.ToVector4());
FxRecolor.CurrentTechnique = FxRecolor.Techniques["Recolor"];
Draw.SpriteBatch.End();
Draw.SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointWrap, DepthStencilState.None, RasterizerState.CullNone, FxRecolor, (self.Scene as Level).GameplayRenderer.Camera.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, (self.Scene as Level).GameplayRenderer.Camera.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))
) {
if (PlayerConfig.IsEnabled(player)) {
trySpriteSwap(self, true);
Color color = PlayerConfig.GetColor(player);
// apply the recolor effect to the sprite
// replace the color #1ud589 in the sprite with color
FxRecolor.Parameters["threshold"].SetValue(0.01f);
FxRecolor.Parameters["color_replace_from"].SetValue(
(new Color((byte) 0x1u, 0xd5, 0x89, 0xff)).ToVector4()
);
FxRecolor.Parameters["color_replace_to"].SetValue(color.ToVector4());
FxRecolor.CurrentTechnique = FxRecolor.Techniques["Recolor"];
Draw.SpriteBatch.End();
Draw.SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointWrap, DepthStencilState.None, RasterizerState.CullNone, FxRecolor, (self.Scene as Level).GameplayRenderer.Camera.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, (self.Scene as Level).GameplayRenderer.Camera.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 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;
AvaliConfig everyoneHasSkin = new AvaliConfig { Enabled = true, ColorMode = ColorMode.ExternalDash};
Color color;
Ghost ghost;
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;
color = data.Config.GetColor(ghost);
} else if (
Settings.CelesteNetEveryoneHasSkin
&& self.Scene != null && self.Entity != null
&& self.Entity is Ghost ghost3
&& everyoneHasSkin.IsEnabled(ghost3)
) {
ghost = ghost3;
color = everyoneHasSkin.GetColor(ghost);
} 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:
// replace the color #1ud589 in the sprite with color
FxRecolor.Parameters["threshold"].SetValue(0.01f);
FxRecolor.Parameters["color_replace_from"].SetValue(
(new Color((byte) 0x1u, 0xd5, 0x89, 0xff)).ToVector4()
);
FxRecolor.Parameters["color_replace_to"].SetValue(color.ToVector4());
FxRecolor.CurrentTechnique = FxRecolor.Techniques["Recolor"];
Draw.SpriteBatch.End();
Draw.SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointWrap, DepthStencilState.None, RasterizerState.CullNone, FxRecolor, (self.Scene as Level).GameplayRenderer.Camera.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, (self.Scene as Level).GameplayRenderer.Camera.Matrix);
}
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) {
Color orig_color = orig(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.ColorMode != ColorMode.ExternalDash) {
// replace trail colors with marking colors
return PlayerConfig.GetColor(self);
}
}
// skin disabled, keep original colors
return orig_color;
}
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.ColorMode != ColorMode.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);
}
}
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.ColorMode != ColorMode.ExternalDash
)) {
// disabled, just run vanilla code
return orig(self);
}
Color color = PlayerConfig.GetColor(self);
// 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 = Player.P_DashA;
Player.P_DashBadB = 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;
}
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");
}
}
}
}