isaacscript-common
Version:
Helper functions and features for IsaacScript mods.
878 lines (758 loc) • 30.2 kB
text/typescript
import {
ButtonAction,
Direction,
EntityCollisionClass,
EntityGridCollisionClass,
EntityPartition,
GridCollisionClass,
LevelStage,
ModCallback,
PlayerType,
RoomTransitionAnim,
RoomType,
StageType,
} from "isaac-typescript-definitions";
import { game } from "../../../core/cachedClasses";
import { VectorZero } from "../../../core/constants";
import { Exported } from "../../../decorators";
import { ISCFeature } from "../../../enums/ISCFeature";
import { ModCallbackCustom } from "../../../enums/ModCallbackCustom";
import { GridEntityTypeCustom } from "../../../enums/private/GridEntityTypeCustom";
import { StageTravelState } from "../../../enums/private/StageTravelState";
import { TrapdoorAnimation } from "../../../enums/private/TrapdoorAnimation";
import { easeOutSine } from "../../../functions/easing";
import {
isAfterRoomFrame,
isBeforeRenderFrame,
onOrAfterRenderFrame,
} from "../../../functions/frames";
import { log } from "../../../functions/log";
import { movePlayersToCenter } from "../../../functions/playerCenter";
import {
getAllPlayers,
getOtherPlayers,
isChildPlayer,
} from "../../../functions/playerIndex";
import { isCharacter } from "../../../functions/players";
import { anyPlayerCloserThan } from "../../../functions/positionVelocity";
import {
getRoomGridIndex,
getRoomListIndex,
} from "../../../functions/roomData";
import { teleport } from "../../../functions/roomTransition";
import { setStage } from "../../../functions/stage";
import { getTSTLClassName } from "../../../functions/tstlClass";
import { assertDefined } from "../../../functions/utils";
import { isVector } from "../../../functions/vector";
import type { CustomTrapdoorDescription } from "../../../interfaces/private/CustomTrapdoorDescription";
import { ReadonlySet } from "../../../types/ReadonlySet";
import { DefaultMap } from "../../DefaultMap";
import { Feature } from "../../private/Feature";
import type { CustomGridEntities } from "../callbackLogic/CustomGridEntities";
import type { DisableInputs } from "./DisableInputs";
import type { PonyDetection } from "./PonyDetection";
import type { RoomClearFrame } from "./RoomClearFrame";
import type { RunInNFrames } from "./RunInNFrames";
import type { RunNextRoom } from "./RunNextRoom";
import type { StageHistory } from "./StageHistory";
import { CUSTOM_FLOOR_STAGE } from "./customStages/constants";
const DEBUG = false as boolean;
/**
* This also applies to crawl spaces. The value was determined through trial and error to match
* vanilla behavior.
*/
const TRAPDOOR_OPEN_DISTANCE = 60;
const TRAPDOOR_OPEN_DISTANCE_AFTER_BOSS = TRAPDOOR_OPEN_DISTANCE * 2.5;
const TRAPDOOR_BOSS_REACTION_FRAMES = 30;
const TRAPDOOR_TOUCH_DISTANCE = 16.5;
const ANIMATIONS_THAT_PREVENT_STAGE_TRAVEL = new ReadonlySet<string>([
"Death",
"Happy",
"Sad",
"Jump",
]);
const PIXELATION_TO_BLACK_FRAMES = 60;
const OTHER_PLAYER_TRAPDOOR_JUMP_DELAY_GAME_FRAMES = 6;
const OTHER_PLAYER_TRAPDOOR_JUMP_DURATION_GAME_FRAMES = 5;
const v = {
run: {
state: StageTravelState.NONE,
/** The render frame that this state was reached. */
stateRenderFrame: null as int | null,
customTrapdoorActivated: null as CustomTrapdoorDescription | null,
},
level: {
/** Indexed by room list index and grid index. */
trapdoors: new DefaultMap<int, Map<int, CustomTrapdoorDescription>>(
() => new Map(),
),
},
};
export class CustomTrapdoors extends Feature {
/** Indexed by custom trapdoor ID. */
private readonly destinationFuncMap = new Map<
string,
(
destinationName: string | undefined,
destinationStage: LevelStage,
destinationStageType: StageType,
) => void
>();
/** @internal */
public override v = v;
/**
* In order to represent a black sprite, we just use the first frame of the boss versus screen
* animation. However, we must lazy load the sprite in order to prevent issues with mods that
* replace the vanilla files. (For some reason, loading the sprites will cause the overwrite to no
* longer apply on the second and subsequent runs.)
*/
private readonly blackSprite = Sprite();
private readonly customGridEntities: CustomGridEntities;
private readonly disableInputs: DisableInputs;
private readonly ponyDetection: PonyDetection;
private readonly roomClearFrame: RoomClearFrame;
private readonly runInNFrames: RunInNFrames;
private readonly runNextRoom: RunNextRoom;
private readonly stageHistory: StageHistory;
/** @internal */
constructor(
customGridEntities: CustomGridEntities,
disableInputs: DisableInputs,
ponyDetection: PonyDetection,
roomClearFrame: RoomClearFrame,
runInNFrames: RunInNFrames,
runNextRoom: RunNextRoom,
stageHistory: StageHistory,
) {
super();
this.featuresUsed = [
ISCFeature.CUSTOM_GRID_ENTITIES,
ISCFeature.DISABLE_INPUTS,
ISCFeature.PONY_DETECTION,
ISCFeature.ROOM_CLEAR_FRAME,
ISCFeature.RUN_IN_N_FRAMES,
ISCFeature.RUN_NEXT_ROOM,
ISCFeature.STAGE_HISTORY,
];
this.callbacksUsed = [
// 2
[ModCallback.POST_RENDER, this.postRender],
];
this.customCallbacksUsed = [
[
ModCallbackCustom.POST_GRID_ENTITY_CUSTOM_UPDATE,
this.postGridEntityCustomUpdateTrapdoor,
[GridEntityTypeCustom.TRAPDOOR_CUSTOM],
],
[
ModCallbackCustom.POST_PEFFECT_UPDATE_REORDERED,
this.postPEffectUpdateReordered,
],
];
this.customGridEntities = customGridEntities;
this.disableInputs = disableInputs;
this.ponyDetection = ponyDetection;
this.roomClearFrame = roomClearFrame;
this.runInNFrames = runInNFrames;
this.runNextRoom = runNextRoom;
this.stageHistory = stageHistory;
}
// ModCallback.POST_RENDER (2)
private readonly postRender = () => {
this.checkAllPlayersJumpComplete();
this.checkPixelationToBlackComplete();
this.checkSecondPixelationHalfWay();
this.checkAllPlayersLayingDownComplete();
this.drawBlackSprite();
};
private checkAllPlayersJumpComplete() {
if (v.run.state !== StageTravelState.PLAYERS_JUMPING_DOWN) {
return;
}
if (anyPlayerPlayingExtraAnimation()) {
return;
}
const renderFrameCount = Isaac.GetFrameCount();
const roomGridIndex = getRoomGridIndex();
v.run.state = StageTravelState.PIXELATION_TO_BLACK;
v.run.stateRenderFrame = renderFrameCount;
this.logStateChanged();
// In order to display the pixelation effect that should happen when we go to a new floor, we
// need to start a room transition. We arbitrarily pick the current room for this purpose. (We
// do not have to worry about Curse of the Maze here, because even if we are taken to a
// different room, it will not matter, since we will be traveling to a new floor after the
// screen fades to black.)
teleport(
roomGridIndex,
Direction.NO_DIRECTION,
RoomTransitionAnim.PIXELATION,
);
// Next, we wait a certain amount of render frames for the pixelation to fade the screen to
// black.
}
private checkPixelationToBlackComplete() {
if (
v.run.state !== StageTravelState.PIXELATION_TO_BLACK
|| v.run.stateRenderFrame === null
) {
return;
}
const renderFrameScreenBlack =
v.run.stateRenderFrame + PIXELATION_TO_BLACK_FRAMES;
if (isBeforeRenderFrame(renderFrameScreenBlack)) {
return;
}
v.run.state = StageTravelState.WAITING_FOR_FIRST_PIXELATION_TO_END;
this.logStateChanged();
// Now, we display a black sprite on top of the pixelation effect, to prevent showing the rest
// of the animation.
const hud = game.GetHUD();
hud.SetVisible(false);
// If the pixelation effect is not fully allowed to complete, the game's internal buffer will
// not be flushed. The consequence of this is that after 11 custom stage transitions, the
// "log.txt" starts to become become spammed with: [ASSERT] - PushRenderTarget: stack overflow!
// In order to work around this, we fully let the animation complete by only continuing the
// stage transition on the next game frame.
this.runInNFrames.runNextGameFrame(() => {
const level = game.GetLevel();
const startingRoomIndex = level.GetStartingRoomIndex();
const futureRenderFrameCount = Isaac.GetFrameCount();
v.run.state =
StageTravelState.WAITING_FOR_SECOND_PIXELATION_TO_GET_HALF_WAY;
v.run.stateRenderFrame = futureRenderFrameCount;
this.goToCustomTrapdoorDestination();
// Start another pixelation effect. This time, we will keep the screen black with the sprite,
// and then remove the black sprite once the pixelation effect is halfway complete.
teleport(
startingRoomIndex,
Direction.NO_DIRECTION,
RoomTransitionAnim.PIXELATION,
);
});
}
private goToCustomTrapdoorDestination() {
// This should never be null. Regardless, we provide some sane default values.
v.run.customTrapdoorActivated ??= {
destinationName: undefined,
destinationStage: LevelStage.BASEMENT_1,
destinationStageType: StageType.ORIGINAL,
open: true,
firstSpawn: true,
};
const destinationFunc = this.getDestinationFunc(
v.run.customTrapdoorActivated,
);
destinationFunc(
v.run.customTrapdoorActivated.destinationName,
v.run.customTrapdoorActivated.destinationStage,
v.run.customTrapdoorActivated.destinationStageType,
);
}
private getDestinationFunc(
customTrapdoorDescription: CustomTrapdoorDescription,
): (
destinationName: string | undefined,
destinationStage: LevelStage,
destinationStageType: StageType,
) => void {
if (customTrapdoorDescription.destinationName === undefined) {
return goToVanillaStage;
}
const destinationFunc = this.destinationFuncMap.get(
customTrapdoorDescription.destinationName,
);
if (destinationFunc === undefined) {
return goToVanillaStage;
}
return destinationFunc;
}
private checkSecondPixelationHalfWay() {
if (
v.run.state
!== StageTravelState.WAITING_FOR_SECOND_PIXELATION_TO_GET_HALF_WAY
|| v.run.stateRenderFrame === null
) {
return;
}
const renderFrameScreenBlack =
v.run.stateRenderFrame + PIXELATION_TO_BLACK_FRAMES;
if (isBeforeRenderFrame(renderFrameScreenBlack)) {
return;
}
v.run.state = StageTravelState.PIXELATION_TO_ROOM;
this.logStateChanged();
const hud = game.GetHUD();
hud.SetVisible(true);
this.runNextRoom.runNextRoom(() => {
v.run.state = StageTravelState.PLAYERS_LAYING_DOWN;
this.logStateChanged();
// After the room transition, the players will be placed next to a door, but they should be in
// the center of the room to emulate what happens on a vanilla stage.
movePlayersToCenter();
for (const player of getAllPlayers()) {
player.AnimateAppear();
// We need to restore the original collision classes.
player.EntityCollisionClass = EntityCollisionClass.ALL;
player.GridCollisionClass = EntityGridCollisionClass.GROUND;
}
const level = game.GetLevel();
const stage = level.GetStage();
if (stage !== CUSTOM_FLOOR_STAGE) {
// The vanilla streak text shows just before the player stands up, which is a few frames
// from now. We arbitrarily play it now instead of waiting to avoid the extra complexity.
level.ShowName(false);
}
});
}
private checkAllPlayersLayingDownComplete() {
if (v.run.state !== StageTravelState.PLAYERS_LAYING_DOWN) {
return;
}
if (anyPlayerPlayingExtraAnimation()) {
return;
}
v.run.state = StageTravelState.NONE;
this.logStateChanged();
const tstlClassName = getTSTLClassName(this);
assertDefined(
tstlClassName,
"Failed to find get the class name for the custom trapdoor feature.",
);
this.disableInputs.enableAllInputs(tstlClassName);
}
private drawBlackSprite(): void {
if (
v.run.state !== StageTravelState.WAITING_FOR_FIRST_PIXELATION_TO_END
&& v.run.state
!== StageTravelState.WAITING_FOR_SECOND_PIXELATION_TO_GET_HALF_WAY
) {
return;
}
if (!this.blackSprite.IsLoaded()) {
this.blackSprite.Load("gfx/ui/boss/versusscreen.anm2", true);
this.blackSprite.SetFrame("Scene", 0);
this.blackSprite.Scale = Vector(100, 100);
}
this.blackSprite.RenderLayer(0, VectorZero);
}
// ModCallbackCustom.POST_GRID_ENTITY_CUSTOM_UPDATE
// GridEntityTypeCustom.TRAPDOOR_CUSTOM
private readonly postGridEntityCustomUpdateTrapdoor = (
gridEntity: GridEntity,
) => {
const roomListIndex = getRoomListIndex();
const gridIndex = gridEntity.GetGridIndex();
const roomTrapdoorMap = v.level.trapdoors.getAndSetDefault(roomListIndex);
const trapdoorDescription = roomTrapdoorMap.get(gridIndex);
if (trapdoorDescription === undefined) {
return;
}
this.checkCustomTrapdoorOpenClose(gridEntity, trapdoorDescription);
this.checkCustomTrapdoorPlayerTouched(gridEntity, trapdoorDescription);
};
private checkCustomTrapdoorOpenClose(
gridEntity: GridEntity,
trapdoorDescription: CustomTrapdoorDescription,
): void {
/** By default, trapdoors will never close if they are already open. */
if (trapdoorDescription.open) {
// Sometimes, the `sprite.Play(TrapdoorAnimation.OPEN_ANIMATION, true)` function does not take
// effect. Check for this case.
const sprite = gridEntity.GetSprite();
const animation = sprite.GetAnimation();
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (animation === TrapdoorAnimation.CLOSED) {
// Try it again.
sprite.Play(TrapdoorAnimation.OPEN_ANIMATION, true);
}
return;
}
if (this.shouldTrapdoorOpen(gridEntity, trapdoorDescription.firstSpawn)) {
openCustomTrapdoor(gridEntity, trapdoorDescription);
}
}
private shouldTrapdoorOpen(
gridEntity: GridEntity,
firstSpawn: boolean,
): boolean {
const room = game.GetRoom();
const roomClear = room.IsClear();
return (
!anyPlayerCloserThan(gridEntity.Position, TRAPDOOR_OPEN_DISTANCE)
&& !this.isPlayerCloseAfterBoss(gridEntity.Position)
&& !shouldBeClosedFromStartingInRoomWithEnemies(firstSpawn, roomClear)
);
}
private isPlayerCloseAfterBoss(position: Vector) {
const room = game.GetRoom();
const roomType = room.GetType();
const roomClearGameFrame = this.roomClearFrame.getRoomClearGameFrame();
// In order to prevent a player from accidentally entering a freshly-spawned trapdoor after
// killing the boss of the floor, we use a wider open distance for a short amount of frames.
if (
roomType !== RoomType.BOSS
|| roomClearGameFrame === undefined
|| onOrAfterRenderFrame(
roomClearGameFrame + TRAPDOOR_BOSS_REACTION_FRAMES,
)
) {
return false;
}
return anyPlayerCloserThan(position, TRAPDOOR_OPEN_DISTANCE_AFTER_BOSS);
}
private checkCustomTrapdoorPlayerTouched(
gridEntity: GridEntity,
trapdoorDescription: CustomTrapdoorDescription,
): void {
if (v.run.state !== StageTravelState.NONE) {
return;
}
if (!trapdoorDescription.open) {
return;
}
const playersTouching = Isaac.FindInRadius(
gridEntity.Position,
TRAPDOOR_TOUCH_DISTANCE,
EntityPartition.PLAYER,
);
for (const playerEntity of playersTouching) {
const player = playerEntity.ToPlayer();
if (player === undefined) {
continue;
}
if (
// We don't want a Pony dash to transition to a new floor or a crawl space.
!this.ponyDetection.isPlayerUsingPony(player)
&& !isChildPlayer(player)
&& canPlayerInteractWithTrapdoor(player)
) {
this.playerTouchedCustomTrapdoor(
gridEntity,
trapdoorDescription,
player,
);
return; // Prevent two players from touching the same entity.
}
}
}
private playerTouchedCustomTrapdoor(
gridEntity: GridEntity,
trapdoorDescription: CustomTrapdoorDescription,
player: EntityPlayer,
) {
v.run.state = StageTravelState.PLAYERS_JUMPING_DOWN;
v.run.customTrapdoorActivated = trapdoorDescription;
this.logStateChanged();
const tstlClassName = getTSTLClassName(this);
assertDefined(
tstlClassName,
"Failed to find get the class name for the custom trapdoor feature.",
);
// We don't want to allow pausing, since that will allow render frames to pass without advancing
// the stage traveling logic. (We track how many render frames have passed to know when to move
// to the next step.)
const whitelist = new ReadonlySet([ButtonAction.CONSOLE]);
this.disableInputs.disableAllInputsExceptFor(tstlClassName, whitelist);
setPlayerAttributes(player, gridEntity.Position);
dropTaintedForgotten(player);
player.PlayExtraAnimation("Trapdoor");
const otherPlayers = getOtherPlayers(player);
for (const [i, otherPlayer] of otherPlayers.entries()) {
const gameFramesToWaitBeforeJumping =
OTHER_PLAYER_TRAPDOOR_JUMP_DELAY_GAME_FRAMES * (i + 1);
const otherPlayerPtr = EntityPtr(otherPlayer);
this.runInNFrames.runInNGameFrames(() => {
this.startDelayedJump(otherPlayerPtr, gridEntity.Position);
}, gameFramesToWaitBeforeJumping);
}
}
private startDelayedJump(entityPtr: EntityPtr, trapdoorPosition: Vector) {
const entity = entityPtr.Ref;
if (entity === undefined) {
return;
}
const player = entity.ToPlayer();
if (player === undefined) {
return;
}
player.PlayExtraAnimation("Trapdoor");
this.adjustPlayerPositionToTrapdoor(
entityPtr,
player.Position,
trapdoorPosition,
);
}
private adjustPlayerPositionToTrapdoor(
entityPtr: EntityPtr,
startPos: Vector,
endPos: Vector,
) {
if (v.run.state !== StageTravelState.PLAYERS_JUMPING_DOWN) {
return;
}
const entity = entityPtr.Ref;
if (entity === undefined) {
return;
}
const player = entity.ToPlayer();
if (player === undefined) {
return;
}
this.runInNFrames.runNextRenderFrame(() => {
this.adjustPlayerPositionToTrapdoor(entityPtr, startPos, endPos);
});
const sprite = player.GetSprite();
if (sprite.IsFinished("Trapdoor")) {
player.Position = endPos;
player.Velocity = VectorZero;
return;
}
const frame = sprite.GetFrame();
if (frame >= OTHER_PLAYER_TRAPDOOR_JUMP_DURATION_GAME_FRAMES) {
// We have already arrived at the trapdoor.
player.Position = endPos;
player.Velocity = VectorZero;
return;
}
// Make the player jump towards the trapdoor. We use an easing function so that the distance
// traveled is not linear, emulating what the game does.
const totalDifference = endPos.sub(startPos);
const progress = frame / OTHER_PLAYER_TRAPDOOR_JUMP_DURATION_GAME_FRAMES;
const easeProgress = easeOutSine(progress);
const differenceForThisFrame = totalDifference.mul(easeProgress);
const targetPosition = startPos.add(differenceForThisFrame);
player.Position = targetPosition;
player.Velocity = VectorZero;
}
// ModCallbackCustom.POST_PEFFECT_UPDATE_REORDERED
private readonly postPEffectUpdateReordered = (player: EntityPlayer) => {
this.checkJumpComplete(player);
};
private checkJumpComplete(player: EntityPlayer) {
if (v.run.state !== StageTravelState.PLAYERS_JUMPING_DOWN) {
return;
}
// In this state, the players are jumping down the hole (i.e. playing the "Trapdoor" animation).
// When it completes, they will return to normal (i.e. just standing on top of the trapdoor).
// Thus, make them invisible at the end of the animation. (They will automatically be set to
// visible again once we travel to the next floor.)
const sprite = player.GetSprite();
if (sprite.IsFinished("Trapdoor")) {
player.Visible = false;
}
}
private shouldTrapdoorSpawnOpen(
gridEntity: GridEntity,
firstSpawn: boolean,
): boolean {
const room = game.GetRoom();
const roomClear = room.IsClear();
// Trapdoors created after a room has already initialized should spawn closed by default:
// - Trapdoors created after bosses should spawn closed so that players do not accidentally jump
// into them.
// - Trapdoors created by We Need to Go Deeper should spawn closed because the player will be
// standing on top of them.
if (isAfterRoomFrame(0)) {
return false;
}
// If we just entered a new room with enemies in it, spawn the trapdoor closed so that the
// player has to defeat the enemies first before using the trapdoor.
if (!roomClear) {
return false;
}
// If we just entered a new room that is already cleared, spawn the trapdoor closed if we are
// standing close to it, and open otherwise.
return this.shouldTrapdoorOpen(gridEntity, firstSpawn);
}
private logStateChanged(): void {
if (DEBUG) {
log(
`Custom trapdoors state changed: ${StageTravelState[v.run.state]} (${
v.run.state
})`,
);
}
}
/**
* Helper function to specify where your custom trapdoor should take the player. Call this once at
* the beginning of your mod for each kind of custom trapdoor that you want to have. The provided
* `destinationFunc` will be executed when the player jumps into the trapdoor and the pixelation
* effect fades to black.
*
* Registration is needed so that custom trapdoors can be serializable when the player saves and
* quits.
*
* In order to use this function, you must upgrade your mod with `ISCFeature.CUSTOM_TRAPDOORS`.
*
* @param destinationName The name that identifies the type of custom trapdoor. It should
* correspond to a local `CustomTrapdoorType` enum in your mod. It can be
* any unique value and can safely overlap with values chosen by other
* mods.
* @param destinationFunc A function that takes the player to the destination that you want.
* Inside this function, use the `setStage` or `setCustomStage` helper
* functions, or do something completely custom.
*/
public registerCustomTrapdoorDestination(
destinationName: string,
destinationFunc: (
destinationName: string | undefined,
destinationStage: LevelStage,
destinationStageType: StageType,
) => void,
): void {
if (this.destinationFuncMap.has(destinationName)) {
error(
`Failed to register a custom trapdoor type of ${destinationName} since this custom trapdoor type has already been registered.`,
);
}
this.destinationFuncMap.set(destinationName, destinationFunc);
}
/**
* Helper function to spawn a trapdoor grid entity that will take a player to a vanilla stage or
* custom location.
*
* - If you want to create a custom trapdoor that goes to a vanilla stage, pass `undefined` for
* the `destinationName` parameter.
* - If you want to create a custom trapdoor that takes the player to a custom location, you must
* have registered the corresponding `destinationName` at the beginning of your mod with the
* `registerCustomTrapdoorDestination` function. (This is necessary so that custom trapdoors can
* be serializable when the player saves and quits.)
*
* Under the hood, the custom trapdoor is represented by a decoration grid entity and is manually
* respawned every time the player re-enters the room.
*
* In order to use this function, you must upgrade your mod with `ISCFeature.CUSTOM_TRAPDOORS`.
*
* @param gridIndexOrPosition The location in the room to spawn the trapdoor.
* @param destinationName Optional. A string representing the name of the of destination that the
* custom trapdoor will take the player to. Default is undefined, which
* will take the player to a vanilla stage.
* @param destinationStage Optional. The first argument that will be passed to the
* `destinationFunc` corresponding to this custom trapdoor. This is
* essentially metadata for the custom trapdoor. Leave this undefined if
* your corresponding custom trapdoor function does not care what the
* destination stage should be. Default is the "normal" next vanilla
* stage.
* @param destinationStageType Optional. The second argument that will be passed to the
* `destinationFunc` corresponding to this custom trapdoor. This is
* essentially metadata for the custom trapdoor. Leave this undefined
* if your corresponding custom trapdoor function does not care what
* the destination stage type should be. Default is the "normal" next
* vanilla stage type.
* @param anm2Path Optional. The path to the anm2 file to use. By default, the vanilla trapdoor
* anm2 of "gfx/grid/door_11_trapdoor.anm2" will be used. The specified anm2 file
* must have animations called "Opened", "Closed", and "Open Animation".
* @param spawnOpen Optional. Whether to spawn the trapdoor in an open state. By default, behavior
* will be used that emulates a vanilla trapdoor.
*/
public spawnCustomTrapdoor(
gridIndexOrPosition: int | Vector,
destinationName?: string,
destinationStage?: LevelStage,
destinationStageType?: StageType,
anm2Path = "gfx/grid/door_11_trapdoor.anm2",
spawnOpen?: boolean,
): GridEntity {
if (
destinationName !== undefined
&& !this.destinationFuncMap.has(destinationName)
) {
error(
`Failed to spawn a custom trapdoor with a destination of "${destinationName}" since a destination with that name has not been registered with the "registerCustomTrapdoorDestination" function. (If you are trying to go to a custom stage, the custom stage library should automatically do this for you when your mod first boots.)`,
);
}
destinationStage ??= this.stageHistory.getNextStageWithHistory();
destinationStageType ??= this.stageHistory.getNextStageTypeWithHistory();
const room = game.GetRoom();
const roomListIndex = getRoomListIndex();
const gridIndex = isVector(gridIndexOrPosition)
? room.GetGridIndex(gridIndexOrPosition)
: gridIndexOrPosition;
const gridEntity = this.customGridEntities.spawnCustomGridEntity(
GridEntityTypeCustom.TRAPDOOR_CUSTOM,
gridIndexOrPosition,
GridCollisionClass.NONE,
anm2Path,
TrapdoorAnimation.OPENED,
);
const firstSpawn = isAfterRoomFrame(0);
const open =
spawnOpen ?? this.shouldTrapdoorSpawnOpen(gridEntity, firstSpawn);
const roomTrapdoorMap = v.level.trapdoors.getAndSetDefault(roomListIndex);
const customTrapdoorDescription: CustomTrapdoorDescription = {
destinationName,
destinationStage,
destinationStageType,
open,
firstSpawn,
};
roomTrapdoorMap.set(gridIndex, customTrapdoorDescription);
const sprite = gridEntity.GetSprite();
const animation = open
? TrapdoorAnimation.OPENED
: TrapdoorAnimation.CLOSED;
sprite.Play(animation, true);
return gridEntity;
}
}
function anyPlayerPlayingExtraAnimation() {
const players = getAllPlayers();
return players.some((player) => !player.IsExtraAnimationFinished());
}
function shouldBeClosedFromStartingInRoomWithEnemies(
firstSpawn: boolean,
roomClear: boolean,
) {
return firstSpawn && !roomClear;
}
function openCustomTrapdoor(
gridEntity: GridEntity,
trapdoorDescription: CustomTrapdoorDescription,
) {
trapdoorDescription.open = true;
const sprite = gridEntity.GetSprite();
sprite.Play(TrapdoorAnimation.OPEN_ANIMATION, true);
}
function canPlayerInteractWithTrapdoor(player: EntityPlayer) {
// Players cannot interact with stage travel entities when items are queued or while playing
// certain animations.
const sprite = player.GetSprite();
const animation = sprite.GetAnimation();
return (
!player.IsHoldingItem()
&& !ANIMATIONS_THAT_PREVENT_STAGE_TRAVEL.has(animation)
);
}
function setPlayerAttributes(trapdoorPlayer: EntityPlayer, position: Vector) {
// Snap the player to the exact position of the trapdoor so that they cleanly jump down the hole.
trapdoorPlayer.Position = position;
for (const player of getAllPlayers()) {
// Disable the controls to prevent the player from moving, shooting, and so on. (We also disable
// the inputs in the `INPUT_ACTION` callback, but that does not prevent mouse inputs.)
player.ControlsEnabled = false;
// Freeze all players.
player.Velocity = VectorZero;
// We don't want enemy attacks to move the players.
player.EntityCollisionClass = EntityCollisionClass.NONE;
player.GridCollisionClass = EntityGridCollisionClass.NONE;
player.StopExtraAnimation();
}
}
function dropTaintedForgotten(player: EntityPlayer) {
if (isCharacter(player, PlayerType.FORGOTTEN_B)) {
const taintedSoul = player.GetOtherTwin();
if (taintedSoul !== undefined) {
taintedSoul.ThrowHeldEntity(VectorZero);
}
}
}
/** The default `destinationFunc` for a custom trapdoor. */
function goToVanillaStage(
_destinationName: string | undefined,
destinationStage: LevelStage,
destinationStageType: StageType,
) {
setStage(destinationStage, destinationStageType);
}