isaacscript-common
Version:
Helper functions and features for IsaacScript mods.
419 lines (380 loc) • 12.5 kB
text/typescript
import type { GridEntityXMLType } from "isaac-typescript-definitions";
import {
EntityCollisionClass,
EntityGridCollisionClass,
EntityType,
GridEntityType,
PickupVariant,
PitfallVariant,
RoomType,
} from "isaac-typescript-definitions";
import { GRID_ENTITY_XML_TYPE_VALUES } from "../../../cachedEnumValues";
import { game } from "../../../core/cachedClasses";
import { Exported } from "../../../decorators";
import { ISCFeature } from "../../../enums/ISCFeature";
import { emptyRoom } from "../../../functions/emptyRoom";
import {
getEntityIDFromConstituents,
spawnWithSeed,
} from "../../../functions/entities";
import {
convertXMLGridEntityType,
getGridEntities,
spawnGridEntityWithVariant,
} from "../../../functions/gridEntities";
import { getRandomJSONEntity } from "../../../functions/jsonRoom";
import { log } from "../../../functions/log";
import { isRNG, newRNG } from "../../../functions/rng";
import { gridCoordinatesToWorldPosition } from "../../../functions/roomGrid";
import { setRoomCleared, setRoomUncleared } from "../../../functions/rooms";
import { spawnCollectible } from "../../../functions/spawnCollectible";
import { asCollectibleType, parseIntSafe } from "../../../functions/types";
import { assertDefined } from "../../../functions/utils";
import type { JSONRoom } from "../../../interfaces/JSONRoomsFile";
import { ReadonlySet } from "../../../types/ReadonlySet";
import { Feature } from "../../private/Feature";
import type { PreventGridEntityRespawn } from "./PreventGridEntityRespawn";
const GRID_ENTITY_XML_TYPE_SET = new ReadonlySet<GridEntityXMLType>(
GRID_ENTITY_XML_TYPE_VALUES,
);
export class DeployJSONRoom extends Feature {
private readonly preventGridEntityRespawn: PreventGridEntityRespawn;
/** @internal */
constructor(preventGridEntityRespawn: PreventGridEntityRespawn) {
super();
this.featuresUsed = [ISCFeature.PREVENT_GRID_ENTITY_RESPAWN];
this.preventGridEntityRespawn = preventGridEntityRespawn;
}
private spawnAllEntities(
jsonRoom: Readonly<JSONRoom>,
rng: RNG,
verbose = false,
) {
let shouldUnclearRoom = false;
for (const jsonSpawn of jsonRoom.spawn) {
const xString = jsonSpawn.$.x;
const x = parseIntSafe(xString);
assertDefined(
x,
`Failed to convert the following x coordinate to an integer (for a spawn): ${xString}`,
);
const yString = jsonSpawn.$.y;
const y = parseIntSafe(yString);
assertDefined(
y,
`Failed to convert the following y coordinate to an integer (for a spawn): ${yString}`,
);
const jsonEntity = getRandomJSONEntity(jsonSpawn.entity, rng);
const entityTypeString = jsonEntity.$.type;
const entityTypeNumber = parseIntSafe(entityTypeString);
assertDefined(
entityTypeNumber,
`Failed to convert the entity type to an integer: ${entityTypeString}`,
);
const variantString = jsonEntity.$.variant;
const variant = parseIntSafe(variantString);
assertDefined(
variant,
`Failed to convert the entity variant to an integer: ${variant}`,
);
const subTypeString = jsonEntity.$.subtype;
const subType = parseIntSafe(subTypeString);
assertDefined(
subType,
`Failed to convert the entity sub-type to an integer: ${subType}`,
);
const isGridEntity = GRID_ENTITY_XML_TYPE_SET.has(
entityTypeNumber as GridEntityXMLType,
);
if (isGridEntity) {
const gridEntityXMLType = entityTypeNumber as GridEntityXMLType;
if (verbose) {
log(
`Spawning grid entity ${gridEntityXMLType}.${variant} at: (${x}, ${y})`,
);
}
spawnGridEntityForJSONRoom(gridEntityXMLType, variant, x, y);
} else {
const entityType = entityTypeNumber as EntityType;
if (verbose) {
const entityID = getEntityIDFromConstituents(
entityType,
variant,
subType,
);
log(`Spawning normal entity ${entityID} at: (${x}, ${y})`);
}
const entity = this.spawnNormalEntityForJSONRoom(
entityType,
variant,
subType,
x,
y,
rng,
);
const npc = entity.ToNPC();
if (npc !== undefined && npc.CanShutDoors) {
shouldUnclearRoom = true;
}
}
}
// After emptying the room, we manually cleared the room. However, if the room layout contains
// an battle NPC, then we need to reset the clear state and close the doors again.
if (shouldUnclearRoom) {
if (verbose) {
log(
"Setting the room to be uncleared since there were one or more battle NPCs spawned.",
);
}
setRoomUncleared();
} else if (verbose) {
log("Leaving the room cleared since there were no battle NPCs spawned.");
}
}
private spawnNormalEntityForJSONRoom(
entityType: EntityType,
variant: int,
subType: int,
x: int,
y: int,
rng: RNG,
) {
const room = game.GetRoom();
const roomType = room.GetType();
const position = gridCoordinatesToWorldPosition(x, y);
const seed = rng.Next();
let entity: Entity;
if (
entityType === EntityType.PICKUP
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
&& variant === PickupVariant.COLLECTIBLE
) {
const collectibleType = asCollectibleType(subType);
const options = roomType === RoomType.ANGEL;
entity = spawnCollectible(collectibleType, position, seed, options);
} else {
entity = spawnWithSeed(entityType, variant, subType, position, seed);
}
// For some reason, Pitfalls do not spawn with the correct collision classes.
if (
entityType === EntityType.PITFALL
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
&& variant === PitfallVariant.PITFALL
) {
entity.EntityCollisionClass = EntityCollisionClass.ENEMIES;
entity.GridCollisionClass = EntityGridCollisionClass.WALLS;
}
return entity;
}
/**
* Helper function to deconstruct a vanilla room and set up a custom room in its place.
* Specifically, this will clear the current room of all entities and grid entities, and then
* spawn all of the entries and grid entities in the provided JSON room. For this reason, you must
* be in the actual room in order to use this function.
*
* A JSON room is simply an XML file converted to JSON. You can create JSON rooms by using the
* Basement Renovator room editor to create an XML file, and then convert it to JSON using the
* `convert-xml-to-json` tool (e.g. `npx convert-xml-to-json my-rooms.xml`).
*
* This function is meant to be used in the `POST_NEW_ROOM` callback.
*
* For example:
*
* ```ts
*
* import customRooms from "./customRooms.json";
*
* export function postNewRoom(): void {
* const firstJSONRoom = customRooms.rooms.room[0];
* deployJSONRoom(firstJSONRoom);
* }
* ```
*
* If you want to deploy an unseeded room, you must explicitly pass `undefined` to the `seedOrRNG`
* parameter.
*
* In order to use this function, you must upgrade your mod with `ISCFeature.DEPLOY_JSON_ROOM`.
*
* @param jsonRoom The JSON room to deploy.
* @param seedOrRNG The `Seed` or `RNG` object to use. If an `RNG` object is provided, the
* `RNG.Next` method will be called. If `undefined` is provided, it will default
* to a random seed.
* @param verbose Optional. If specified, will write entries to the "log.txt" file that describe
* what the function is doing. Default is false.
* @public
*/
@Exported
public deployJSONRoom(
jsonRoom: Readonly<JSONRoom>,
seedOrRNG: Seed | RNG | undefined,
verbose = false,
): void {
const rng = isRNG(seedOrRNG) ? seedOrRNG : newRNG(seedOrRNG);
if (verbose) {
log("Starting to empty the room of entities and grid entities.");
}
emptyRoom();
if (verbose) {
log("Finished emptying the room of entities and grid entities.");
}
setRoomCleared();
if (verbose) {
log("Starting to spawn all of the new entities and grid entities.");
}
this.spawnAllEntities(jsonRoom, rng, verbose);
if (verbose) {
log("Finished spawning all of the new entities and grid entities.");
}
fixPitGraphics();
this.preventGridEntityRespawn.preventGridEntityRespawn();
}
}
function spawnGridEntityForJSONRoom(
gridEntityXMLType: GridEntityXMLType,
gridEntityXMLVariant: int,
x: int,
y: int,
) {
const room = game.GetRoom();
const gridEntityTuple = convertXMLGridEntityType(
gridEntityXMLType,
gridEntityXMLVariant,
);
if (gridEntityTuple === undefined) {
return undefined;
}
const [gridEntityType, variant] = gridEntityTuple;
const position = gridCoordinatesToWorldPosition(x, y);
const gridIndex = room.GetGridIndex(position);
const gridEntity = spawnGridEntityWithVariant(
gridEntityType,
variant,
gridIndex,
);
if (gridEntity === undefined) {
return gridEntity;
}
// Prevent poops from playing an appear animation, since that is not supposed to normally happen
// when entering a new room.
if (gridEntityType === GridEntityType.POOP) {
const sprite = gridEntity.GetSprite();
sprite.Play("State1", true);
sprite.SetLastFrame();
}
return gridEntity;
}
/**
* By default, when spawning multiple pits next to each other, the graphics will not "meld"
* together. Thus, now that all of the entities in the room are spawned, we must iterate over the
* pits in the room and manually fix their sprites, if necessary.
*/
function fixPitGraphics() {
const room = game.GetRoom();
const gridWidth = room.GetGridWidth();
const pitMap = getPitMap();
for (const [gridIndex, gridEntity] of pitMap) {
const gridIndexLeft = gridIndex - 1;
const L = pitMap.has(gridIndexLeft);
const gridIndexRight = gridIndex + 1;
const R = pitMap.has(gridIndexRight);
const gridIndexUp = gridIndex - gridWidth;
const U = pitMap.has(gridIndexUp);
const gridIndexDown = gridIndex + gridWidth;
const D = pitMap.has(gridIndexDown);
const gridIndexUpLeft = gridIndex - gridWidth - 1;
const UL = pitMap.has(gridIndexUpLeft);
const gridIndexUpRight = gridIndex - gridWidth + 1;
const UR = pitMap.has(gridIndexUpRight);
const gridIndexDownLeft = gridIndex + gridWidth - 1;
const DL = pitMap.has(gridIndexDownLeft);
const gridIndexDownRight = gridIndex + gridWidth + 1;
const DR = pitMap.has(gridIndexDownRight);
const pitFrame = getPitFrame(L, R, U, D, UL, UR, DL, DR);
const sprite = gridEntity.GetSprite();
sprite.SetFrame(pitFrame);
}
}
function getPitMap(): ReadonlyMap<int, GridEntity> {
const pitMap = new Map<int, GridEntity>();
for (const gridEntity of getGridEntities(GridEntityType.PIT)) {
const gridIndex = gridEntity.GetGridIndex();
pitMap.set(gridIndex, gridEntity);
}
return pitMap;
}
/** The logic in this function is copied from Basement Renovator. */
function getPitFrame(
L: boolean,
R: boolean,
U: boolean,
D: boolean,
UL: boolean,
UR: boolean,
DL: boolean,
DR: boolean,
) {
let F = 0;
// First, check for bitwise frames. (It works for all combinations of just left/up/right/down.)
if (L) {
F |= 1;
}
if (U) {
F |= 2;
}
if (R) {
F |= 4;
}
if (D) {
F |= 8;
}
// Then, check for other combinations.
if (U && L && !UL && !R && !D) {
F = 17;
}
if (U && R && !UR && !L && !D) {
F = 18;
}
if (L && D && !DL && !U && !R) {
F = 19;
}
if (R && D && !DR && !L && !U) {
F = 20;
}
if (L && U && R && D && !UL) {
F = 21;
}
if (L && U && R && D && !UR) {
F = 22;
}
if (U && R && D && !L && !UR) {
F = 25;
}
if (L && U && D && !R && !UL) {
F = 26;
}
if (L && U && R && D && !DL && !DR) {
F = 24;
}
if (L && U && R && D && !UR && !UL) {
F = 23;
}
if (L && U && R && UL && !UR && !D) {
F = 27;
}
if (L && U && R && UR && !UL && !D) {
F = 28;
}
if (L && U && R && !D && !UR && !UL) {
F = 29;
}
if (L && R && D && DL && !U && !DR) {
F = 30;
}
if (L && R && D && DR && !U && !DL) {
F = 31;
}
if (L && R && D && !U && !DL && !DR) {
F = 32;
}
return F;
}