Skip to content

Commit

Permalink
feat: Provide API for custom HUD windows
Browse files Browse the repository at this point in the history
  • Loading branch information
psyGamer committed Feb 16, 2025
1 parent ac8b759 commit 3fd57a6
Show file tree
Hide file tree
Showing 7 changed files with 348 additions and 29 deletions.
27 changes: 13 additions & 14 deletions CelesteTAS-EverestInterop/Source/EverestInterop/InfoHUD/InfoHud.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,19 @@ public static class InfoHud {
private static EaseInSubMenu subMenuItem;
public static Vector2 Size { get; private set; }

[Load]
private static void Load() {
On.Celeste.Level.Render += LevelOnRender;
On.Celeste.Pico8.Emulator.Render += EmulatorOnRender;
On.Monocle.Scene.Render += SceneOnRender;
}

[Unload]
private static void Unload() {
On.Celeste.Level.Render -= LevelOnRender;
On.Celeste.Pico8.Emulator.Render -= EmulatorOnRender;
On.Monocle.Scene.Render -= SceneOnRender;
}

// [Load]
// private static void Load() {
// On.Celeste.Level.Render += LevelOnRender;
// On.Celeste.Pico8.Emulator.Render += EmulatorOnRender;
// On.Monocle.Scene.Render += SceneOnRender;
// }
//
// [Unload]
// private static void Unload() {
// On.Celeste.Level.Render -= LevelOnRender;
// On.Celeste.Pico8.Emulator.Render -= EmulatorOnRender;
// On.Monocle.Scene.Render -= SceneOnRender;
// }

private static void LevelOnRender(On.Celeste.Level.orig_Render orig, Level self) {
orig(self);
Expand Down
26 changes: 26 additions & 0 deletions CelesteTAS-EverestInterop/Source/Gameplay/Events.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ public class PreSceneUpdate : Attribute;
[AttributeUsage(AttributeTargets.Method), MeansImplicitUse]
public class PostSceneUpdate : Attribute;

/// Invoked before the current scene is updated
[AttributeUsage(AttributeTargets.Method), MeansImplicitUse]
public class PreSceneRender : Attribute;

/// Invoked after the current scene was updated
[AttributeUsage(AttributeTargets.Method), MeansImplicitUse]
public class PostSceneRender : Attribute;

/// Invoked while the engine is frozen
[AttributeUsage(AttributeTargets.Method), MeansImplicitUse]
public class EngineFrozenUpdate : Attribute;
Expand Down Expand Up @@ -59,8 +67,26 @@ private static void Load() {
cursor.EmitStaticDelegate($"Event_{nameof(PostSceneUpdate)}", (Scene scene) => AttributeUtils.Invoke<PostSceneUpdate>(scene));
});

typeof(Engine)
.GetMethodInfo(nameof(Engine.RenderCore))!
.IlHook((cursor, _) => {
// PreSceneRender
cursor.GotoNext(MoveType.Before, instr => instr.MatchCallvirt<Scene>(nameof(Scene.BeforeRender)));
cursor.EmitDup();
cursor.EmitStaticDelegate($"Event_{nameof(PreSceneRender)}", (Scene scene) => AttributeUtils.Invoke<PreSceneRender>(scene));

// PostSceneRender
cursor.GotoNext(MoveType.Before, instr => instr.MatchCallvirt<Scene>(nameof(Scene.AfterRender)));
cursor.EmitDup();
cursor.Index++; // Go after callvirt Scene::AfterRender
cursor.MoveBeforeLabels();
cursor.EmitStaticDelegate($"Event_{nameof(PostSceneRender)}", (Scene scene) => AttributeUtils.Invoke<PostSceneRender>(scene));
});

AttributeUtils.CollectOwnMethods<PreSceneUpdate>(typeof(Scene));
AttributeUtils.CollectOwnMethods<PostSceneUpdate>(typeof(Scene));
AttributeUtils.CollectOwnMethods<PreSceneRender>(typeof(Scene));
AttributeUtils.CollectOwnMethods<PostSceneRender>(typeof(Scene));
AttributeUtils.CollectOwnMethods<EngineFrozenUpdate>();
}
}
5 changes: 5 additions & 0 deletions CelesteTAS-EverestInterop/Source/InfoHUD/GameInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ public static IEnumerable<string> Query(Target target, bool forceAllowCodeExecut
yield return TAS.GameInfo.HudInfo;
yield return "===";

// TODO:
// if (TasSettings.InfoTasInput) {
// WriteTasInput(stringBuilder);
// }

if (TasSettings.InfoGame && levelStatus.Value is { } status && sessionData.Value is { } session) {
yield return $"{status}\n[{session.RoomName}] Timer: {session.ChapterTime}";
}
Expand Down
13 changes: 13 additions & 0 deletions CelesteTAS-EverestInterop/Source/InfoHUD/InfoMouse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using TAS.Utils;

namespace TAS.InfoHUD;

/// Provides information about the current mouse cursor
/// Additionally controls the placement of the in-game Info HUD
internal static class InfoMouse {
public static readonly LazyValue<string> Info = new(QueryInfo);

private static string QueryInfo() {
return string.Empty;
}
}
243 changes: 243 additions & 0 deletions CelesteTAS-EverestInterop/Source/InfoHUD/WindowManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
using Celeste;
using Microsoft.Xna.Framework;
using Monocle;
using StudioCommunication.Util;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using TAS.EverestInterop;
using TAS.EverestInterop.InfoHUD;
using TAS.Gameplay;
using TAS.Module;
using TAS.Utils;
using Color = Microsoft.Xna.Framework.Color;
using Rectangle = Microsoft.Xna.Framework.Rectangle;

namespace TAS.InfoHUD;

/// Handles rendering and interactions with the CelesteTAS InfoHUD, and other third-party HUD windows
internal static class WindowManager {
/// Handler for managing the content of HUd windows
public struct Handler(
Func<bool> visibleProvider,
Func<IEnumerable<string>> textProvider,
Func<Vector2>? loadPosition,
Action<Vector2>? storePosition,
Renderer[] renderers
) {
internal int Index = -1;

public bool Visible => visibleProvider();
public IEnumerable<string> Text => textProvider();

public readonly Func<Vector2>? LoadPosition = loadPosition;
public readonly Action<Vector2>? StorePosition = storePosition;
public readonly Renderer[] Renderers = renderers;
}
/// Custom renderer for HUD windows
public readonly struct Renderer(
Func<bool> visibleProvider,
Func<Vector2> sizeProvider,
Action<Vector2> render
) {
public bool Visible => visibleProvider();
public Vector2 Size => sizeProvider();

public void Render(Vector2 position) => render(position);
}

private static readonly List<Handler> handlers = [];
private static readonly List<Vector2> windowPositions = new();
private static readonly List<Vector2> windowSizes = new();

public static void Register(Handler handler) {
handler.Index = handlers.Count;

handlers.Add(handler);
windowPositions.Add(handler.LoadPosition?.Invoke() ?? Vector2.Zero);
windowSizes.Add(Vector2.Zero);
}

[Load]
private static void Load() {
// Handler for CelesteTAS' own InfoHUD
var infoHudHandler = new Handler(InfoHudVisible, InfoHudText, LoadInfoHudPosition, StoreInfoHudPosition, [new Renderer(SubpixelIndicatorVisible, SubpixelIndicatorSize, RenderSubpixelIndicator)]);
Register(infoHudHandler);

var infoHudHandler2 = new Handler(InfoHudVisible, InfoHudText, LoadInfoHudPosition, StoreInfoHudPosition, [new Renderer(SubpixelIndicatorVisible, SubpixelIndicatorSize, RenderSubpixelIndicator)]);
Register(infoHudHandler2);

static bool InfoHudVisible() => TasSettings.InfoHud;
static IEnumerable<string> InfoHudText() => GameInfo.Query(GameInfo.Target.InGameHud);

static Vector2 LoadInfoHudPosition() => TasSettings.InfoPosition;
static void StoreInfoHudPosition(Vector2 pos) {
TasSettings.InfoPosition = pos;
CelesteTasModule.Instance.SaveSettings();
}

static bool SubpixelIndicatorVisible() => TasSettings.InfoSubpixelIndicator;
static Vector2 SubpixelIndicatorSize() => Vector2.Zero;
static void RenderSubpixelIndicator(Vector2 pos) { }
}

private static (Vector2 StartPosition, int HandlerIndex)? dragWindow;

/// Drag-around windows by right-click dragging them while holding down the Info HUD key
[UpdateMeta]
private static void UpdateMeta() {
if (!TasSettings.Enabled || !Engine.Instance.IsActive) {
return;
}

if (MouseInput.Left.Pressed) {
// Start drag - Find the closest window as target
var mousePosition = MouseInput.Position;

int closestIndex = -1;
float closestDist = float.PositiveInfinity;

foreach (var handler in handlers.Where(handler => handler.Visible)) {
var position = windowPositions[handler.Index];
var size = windowSizes[handler.Index];

float leftDist = position.X - mousePosition.X;
float rightDist = mousePosition.X - position.X - size.X;
float topDist = position.Y - mousePosition.Y;
float bottomDist = mousePosition.Y - position.Y - size.Y;

float xDist = Calc.Max(0.0f, leftDist, rightDist);
float yDist = Calc.Max(0.0f, topDist, bottomDist);

float dist = xDist*xDist + yDist*yDist;
$"{handler.Index}: {leftDist} | {rightDist} | {topDist} {bottomDist} => {xDist} {yDist} => {dist}".DebugLog();

Check failure on line 114 in CelesteTAS-EverestInterop/Source/InfoHUD/WindowManager.cs

View workflow job for this annotation

GitHub Actions / Build CelesteTAS

'string' does not contain a definition for 'DebugLog' and no accessible extension method 'DebugLog' accepting a first argument of type 'string' could be found (are you missing a using directive or an assembly reference?)

Check failure on line 114 in CelesteTAS-EverestInterop/Source/InfoHUD/WindowManager.cs

View workflow job for this annotation

GitHub Actions / Build CelesteTAS

'string' does not contain a definition for 'DebugLog' and no accessible extension method 'DebugLog' accepting a first argument of type 'string' could be found (are you missing a using directive or an assembly reference?)
if (dist < closestDist) {
closestIndex = handler.Index;
closestDist = dist;
}
}

if (closestIndex == -1) {
return; // No handler visible
}

dragWindow = (MouseInput.Position, closestIndex);
}
if (dragWindow != null && MouseInput.Left.Check) {
// Continue drag
windowPositions[dragWindow.Value.HandlerIndex] += MouseInput.PositionDelta;
}
if (dragWindow != null && !MouseInput.Left.Check) {
// Release drag
if (Math.Abs((int) (MouseInput.Position.X - dragWindow.Value.StartPosition.X)) > 0.1f ||
Math.Abs((int) (MouseInput.Position.Y - dragWindow.Value.StartPosition.Y)) > 0.1f
) {
handlers[dragWindow.Value.HandlerIndex].StorePosition?.Invoke(windowPositions[dragWindow.Value.HandlerIndex]);
}

dragWindow = null;
}
}

// Reused to reduce allocations
private static readonly StringBuilder textBuilder = new();

/// Renders all currently active HUD windows
[Events.PostSceneRender]
private static void DrawWindows(Scene scene) {
if (!TasSettings.Enabled || !Hotkeys.Initialized || Engine.Scene is GameLoader { loaded: false }) {
return;
}

Draw.SpriteBatch.Begin();

foreach (var handler in handlers.Where(handler => handler.Visible)) {
textBuilder.Clear();
textBuilder.AppendJoin("\n\n", handler.Text);
textBuilder.TrimStart();
textBuilder.TrimEnd();
if (textBuilder.Length == 0) {
textBuilder.AppendLine();
}

string text = textBuilder.ToString();

int viewWidth = Engine.ViewWidth;
int viewHeight = Engine.ViewHeight;

float pixelScale = Engine.ViewWidth / (float) Celeste.Celeste.GameWidth;
float margin = 2.0f * pixelScale;
float padding = 2.0f * pixelScale;
float fontSize = 0.15f * pixelScale * TasSettings.InfoTextSize / 10.0f;

float backgroundAlpha = TasSettings.InfoOpacity / 10.0f;
float foregroundAlpha = 1.0f;

var position = windowPositions[handler.Index];
var textSize = JetBrainsMonoFont.Measure(text) * fontSize;

var size = textSize;
foreach (var renderer in handler.Renderers.Where(renderer => renderer.Visible)) {
var rendererSize = renderer.Size;

if (size.Y != 0) {
size.Y += padding * 2.0f;
}

size.X = Math.Max(size.X, rendererSize.X);
size.Y += rendererSize.Y;
}

float maxX = viewWidth - size.X - margin - padding * 2.0f;
float maxY = viewHeight - size.Y - margin - padding * 2.0f;
if (maxX > 0.0f && maxY > 0.0f) {
position = position.Clamp(margin, margin, maxX, maxY);
}

windowPositions[handler.Index] = position;
windowSizes[handler.Index] = new Vector2(size.X + padding * 2.0f, size.Y + padding * 2.0f);

Rectangle bgRect = new((int) position.X, (int) position.Y, (int) (size.X + padding * 2.0f), (int) (size.Y + padding * 2.0f));

if (TasSettings.InfoMaskedOpacity < 10 && !Hotkeys.InfoHud.Check && (scene.Paused && !Celeste.Input.MenuJournal.Check || scene is Level level && CollidePlayer(level, bgRect))) {
backgroundAlpha *= TasSettings.InfoMaskedOpacity / 10.0f;
foregroundAlpha = backgroundAlpha;
}

Draw.Rect(bgRect, Color.Black * backgroundAlpha);

var drawPosition = new Vector2(position.X + padding, position.Y + padding);
JetBrainsMonoFont.Draw(text,
drawPosition,
justify: Vector2.Zero,
scale: new(fontSize),
color: Color.White * foregroundAlpha);

drawPosition.Y += textSize.Y + padding * 2.0f;
foreach (var renderer in handler.Renderers.Where(renderer => renderer.Visible)) {
renderer.Render(drawPosition);
drawPosition.Y += renderer.Size.Y + padding * 2.0f;
}
}

Draw.SpriteBatch.End();
}

private static bool CollidePlayer(Level level, Rectangle bgRect) {
if (level.GetPlayer() is not { } player) {
return false;
}

Vector2 playerTopLeft = level.WorldToScreen(player.TopLeft) / Engine.Width * Engine.ViewWidth;
Vector2 playerBottomRight = level.WorldToScreen(player.BottomRight) / Engine.Width * Engine.ViewWidth;
Rectangle playerRect = new(
(int) Math.Min(playerTopLeft.X, playerBottomRight.X),
(int) Math.Min(playerTopLeft.Y, playerBottomRight.Y),
(int) Math.Abs(playerTopLeft.X - playerBottomRight.X),
(int) Math.Abs(playerTopLeft.Y - playerBottomRight.Y)
);

return playerRect.Intersects(bgRect);
}
}
Loading

0 comments on commit 3fd57a6

Please sign in to comment.