isaacscript-common
Version:
Helper functions and features for IsaacScript mods.
348 lines (295 loc) • 10.5 kB
text/typescript
// This emulates the vanilla versus screen that shows up when you enter a boss room.
import {
BossID,
PlayerType,
RoomType,
SoundEffect,
StageID,
} from "isaac-typescript-definitions";
import { game, sfxManager } from "../../../../core/cachedClasses";
import { arrayRemove } from "../../../../functions/array";
import { getBosses } from "../../../../functions/bosses";
import { getRoomSubType } from "../../../../functions/roomData";
import { removeCharactersBefore } from "../../../../functions/string";
import { getScreenCenterPos } from "../../../../functions/ui";
import { eRange } from "../../../../functions/utils";
import {
getBossNamePNGFilePath,
getBossPortraitPNGFilePath,
getCharacterNamePNGFilePath,
getCharacterPortraitPNGFilePath,
} from "../../../../functions/versusScreen";
import type { CustomStage } from "../../../../interfaces/private/CustomStage";
import { VERSUS_SCREEN_BACKGROUND_COLORS } from "../../../../objects/versusScreenBackgroundColors";
import { VERSUS_SCREEN_DIRT_SPOT_COLORS } from "../../../../objects/versusScreenDirtSpotColors";
import type { DisableAllSound } from "../DisableAllSound";
import type { Pause } from "../Pause";
import type { RunInNFrames } from "../RunInNFrames";
import {
CUSTOM_FLOOR_STAGE,
CUSTOM_FLOOR_STAGE_TYPE,
CUSTOM_STAGE_FEATURE_NAME,
DEFAULT_BASE_STAGE,
DEFAULT_BASE_STAGE_TYPE,
ISAACSCRIPT_CUSTOM_STAGE_GFX_PATH,
} from "./constants";
import { v } from "./v";
const DEFAULT_STAGE_ID = StageID.BASEMENT;
const VERSUS_SCREEN_ANIMATION_NAME = "Scene";
/** The layers range from 0 to 13. */
const NUM_VERSUS_SCREEN_ANM2_LAYERS = 14;
/** Corresponds to "resources/gfx/ui/boss/versusscreen.anm2". */
enum VersusScreenLayer {
BACKGROUND = 0,
FRAME = 1,
/** The boss dirt spot. */
BOSS_SPOT = 2,
/** The player dirt spot. */
PLAYER_SPOT = 3,
BOSS_PORTRAIT = 4,
PLAYER_PORTRAIT = 5,
PLAYER_NAME = 6,
BOSS_NAME = 7,
VS_TEXT = 8,
BOSS_DOUBLE = 9,
DT_TEXT = 10,
OVERLAY = 11,
/**
* We only need to render either the normal player portrait layer or the alternate player portrait
* layer. Rendering both will cause the player not to shake.
*/
PLAYER_PORTRAIT_ALT = 12,
BOSS_PORTRAIT_GROUND = 13,
BOSS_PORTRAIT_2_GROUND = 14,
}
/** These are the non-special layers that we will render last. */
const OTHER_ANM2_LAYERS: readonly int[] = arrayRemove(
eRange(NUM_VERSUS_SCREEN_ANM2_LAYERS),
VersusScreenLayer.BACKGROUND,
VersusScreenLayer.BOSS_SPOT,
VersusScreenLayer.PLAYER_SPOT,
VersusScreenLayer.OVERLAY,
VersusScreenLayer.PLAYER_PORTRAIT_ALT,
);
const VANILLA_VERSUS_PLAYBACK_SPEED = 0.5;
/** We lazy load the sprite when first needed. */
const versusScreenSprite = Sprite();
/**
* We lazy load the sprite when first needed.
*
* Unfortunately, we must split the background layer into an entirely different sprite so that we
* can color it with the `Color` field.
*/
const versusScreenBackgroundSprite = Sprite();
/**
* We lazy load the sprite when first needed.
*
* Unfortunately, we must split the dirt layer into an entirely different sprite so that we can
* color it with the `Color` field.
*/
const versusScreenDirtSpotSprite = Sprite();
export function playVersusScreenAnimation(
customStage: CustomStage,
disableAllSound: DisableAllSound,
pause: Pause,
runInNFrames: RunInNFrames,
): void {
const room = game.GetRoom();
const roomType = room.GetType();
const roomCleared = room.IsClear();
const hud = game.GetHUD();
if (roomType !== RoomType.BOSS) {
return;
}
if (roomCleared) {
return;
}
if (willVanillaVersusScreenPlay()) {
// Since we are on an invalid stage, the versus screen will have a completely black background.
// Revert to using the background from the default stage.
const level = game.GetLevel();
level.SetStage(DEFAULT_BASE_STAGE, DEFAULT_BASE_STAGE_TYPE);
runInNFrames.runNextGameFrame(() => {
const futureLevel = game.GetLevel();
futureLevel.SetStage(CUSTOM_FLOOR_STAGE, CUSTOM_FLOOR_STAGE_TYPE);
});
return;
}
v.run.showingBossVersusScreen = true;
pause.pause();
hud.SetVisible(false);
disableAllSound.disableAllSound(CUSTOM_STAGE_FEATURE_NAME);
// In vanilla, the "overlay.png" file has a white background. We must convert it to a PNG that
// uses a transparent background in order for the background behind it to be visible. We use the
// same "overlay.png" file as StageAPI uses for this purpose.
if (!versusScreenSprite.IsLoaded()) {
versusScreenSprite.Load("gfx/ui/boss/versusscreen.anm2", false);
versusScreenSprite.ReplaceSpritesheet(
VersusScreenLayer.OVERLAY,
`${ISAACSCRIPT_CUSTOM_STAGE_GFX_PATH}/overlay.png`,
);
}
// Player
{
const { namePNGPath, portraitPNGPath } = getPlayerPNGPaths();
versusScreenSprite.ReplaceSpritesheet(
VersusScreenLayer.PLAYER_NAME,
namePNGPath,
);
versusScreenSprite.ReplaceSpritesheet(
VersusScreenLayer.PLAYER_PORTRAIT,
portraitPNGPath,
);
}
// Boss
{
const { namePNGPath, portraitPNGPath } = getBossPNGPaths(customStage);
const trimmedNamePNGPath = removeCharactersBefore(namePNGPath, "gfx/");
versusScreenSprite.ReplaceSpritesheet(
VersusScreenLayer.BOSS_NAME,
trimmedNamePNGPath,
);
const trimmedPortraitPNGPath = removeCharactersBefore(
portraitPNGPath,
"gfx/",
);
versusScreenSprite.ReplaceSpritesheet(
VersusScreenLayer.BOSS_PORTRAIT,
trimmedPortraitPNGPath,
);
}
versusScreenSprite.LoadGraphics();
if (!versusScreenBackgroundSprite.IsLoaded()) {
versusScreenBackgroundSprite.Load("gfx/ui/boss/versusscreen.anm2", true);
}
let backgroundColor = VERSUS_SCREEN_BACKGROUND_COLORS[DEFAULT_STAGE_ID];
if (customStage.versusScreen?.backgroundColor !== undefined) {
const { r, g, b, a } = customStage.versusScreen.backgroundColor;
backgroundColor = Color(r, g, b, a);
}
versusScreenBackgroundSprite.Color = backgroundColor;
if (!versusScreenDirtSpotSprite.IsLoaded()) {
versusScreenDirtSpotSprite.Load("gfx/ui/boss/versusscreen.anm2", true);
}
let dirtSpotColor = VERSUS_SCREEN_DIRT_SPOT_COLORS[DEFAULT_STAGE_ID];
if (customStage.versusScreen?.dirtSpotColor !== undefined) {
const { r, g, b } = customStage.versusScreen.dirtSpotColor;
dirtSpotColor = Color(r, g, b);
}
versusScreenDirtSpotSprite.Color = dirtSpotColor;
for (const sprite of [
versusScreenBackgroundSprite,
versusScreenDirtSpotSprite,
versusScreenSprite,
]) {
sprite.Play(VERSUS_SCREEN_ANIMATION_NAME, true);
sprite.PlaybackSpeed = VANILLA_VERSUS_PLAYBACK_SPEED;
}
}
function willVanillaVersusScreenPlay() {
const bosses = getBosses();
return bosses.some((boss) => boss.GetBossID() !== 0);
}
/** Use the character of the 0th player. */
function getPlayerPNGPaths(): {
namePNGPath: string;
portraitPNGPath: string;
} {
const player = Isaac.GetPlayer();
const character = player.GetPlayerType();
if (character === PlayerType.POSSESSOR) {
error("Failed to get the player PNG paths since they are a possessor.");
}
const namePNGPath = getCharacterNamePNGFilePath(character);
const portraitPNGPath = getCharacterPortraitPNGFilePath(character);
return { namePNGPath, portraitPNGPath };
}
/** Use the boss of the first boss found. */
function getBossPNGPaths(customStage: CustomStage): {
namePNGPath: string;
portraitPNGPath: string;
} {
// Prefer the PNG paths specified by the end-user, if any.
const paths = getBossPNGPathsCustom(customStage);
if (paths !== undefined) {
return paths;
}
// If this is not a vanilla boss, default to showing question marks.
const bosses = getBosses();
const firstBoss = bosses[0];
const bossID = firstBoss === undefined ? 0 : firstBoss.GetBossID();
if (bossID === 0) {
const questionMarkPath = getBossNamePNGFilePath(BossID.BLUE_BABY);
const namePNGPath = questionMarkPath;
const portraitPNGPath = questionMarkPath;
return { namePNGPath, portraitPNGPath };
}
// If this is a vanilla boss, it will have a boss ID, and we can use the corresponding vanilla
// files.
const namePNGPath = getBossNamePNGFilePath(bossID);
const portraitPNGPath = getBossPortraitPNGFilePath(bossID);
return { namePNGPath, portraitPNGPath };
}
function getBossPNGPathsCustom(
customStage: CustomStage,
): { namePNGPath: string; portraitPNGPath: string } | undefined {
if (customStage.bossPool === undefined) {
return undefined;
}
const roomSubType = getRoomSubType();
const matchingBossEntry = customStage.bossPool.find(
(bossEntry) => bossEntry.subType === roomSubType,
);
if (matchingBossEntry === undefined) {
return undefined;
}
return matchingBossEntry.versusScreen;
}
function finishVersusScreenAnimation(
pause: Pause,
disableAllSound: DisableAllSound,
) {
const hud = game.GetHUD();
v.run.showingBossVersusScreen = false;
pause.unpause();
hud.SetVisible(true);
disableAllSound.enableAllSound(CUSTOM_STAGE_FEATURE_NAME);
// The sound effect only plays once the versus cutscene is over.
sfxManager.Play(SoundEffect.CASTLE_PORTCULLIS);
}
// ModCallback.POST_RENDER (2)
export function versusScreenPostRender(
pause: Pause,
disableAllSound: DisableAllSound,
): void {
if (!v.run.showingBossVersusScreen) {
return;
}
// We do not want to early return when the game is paused because we need to start displaying the
// black screen as soon as the slide animation starts.
if (versusScreenSprite.IsFinished(VERSUS_SCREEN_ANIMATION_NAME)) {
finishVersusScreenAnimation(pause, disableAllSound);
return;
}
const position = getScreenCenterPos();
// First, we render the background.
versusScreenBackgroundSprite.RenderLayer(
VersusScreenLayer.BACKGROUND,
position,
);
versusScreenBackgroundSprite.Update();
// Second, we render the overlay.
versusScreenSprite.RenderLayer(VersusScreenLayer.OVERLAY, position);
// Third, we render the dirt.
versusScreenDirtSpotSprite.RenderLayer(VersusScreenLayer.BOSS_SPOT, position);
versusScreenDirtSpotSprite.RenderLayer(
VersusScreenLayer.PLAYER_SPOT,
position,
);
versusScreenDirtSpotSprite.Update();
// Lastly, we render everything else.
for (const layerID of OTHER_ANM2_LAYERS) {
versusScreenSprite.RenderLayer(layerID, position);
}
versusScreenSprite.Update();
}