Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]
### Fixed
- Improved NPC rendering, positions should now match the game exactly [#130](https://github.com/CCDirectLink/crosscode-map-editor/issues/130), [#132](https://github.com/CCDirectLink/crosscode-map-editor/issues/132)

## [2.4.1] 2026-05-16
### Fixed
- Fixed lightmap rendering using inverted black values
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export class CustomExpressionWidgetComponent extends OverlayWidget<Person> imple
private async getFace() {
const person = (this.settings.person ?? '').replaceAll('.', '/');
let sheet = (await Helper.getJsonPromise('data/characters/' + person) as CharacterSettings | undefined) ?? {};
sheet.jsonTEMPLATES = getNPCTemplates();
sheet.jsonTEMPLATES = await getNPCTemplates();
sheet = prepareSheet(sheet);

let face: Face = sheet.face ?? {};
Expand Down
64 changes: 64 additions & 0 deletions webapp/src/app/services/phaser/entities/registry/direction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Anims, flattenSUBs } from '../../sheet-parser';

// Unit face vectors. Mirrors CC's FACE8.
export const FACE_VECTORS: Record<string, { x: number; y: number }> = {
NORTH: { x: 0, y: -1 },
NORTH_EAST: { x: 1, y: -1 },
EAST: { x: 1, y: 0 },
SOUTH_EAST: { x: 1, y: 1 },
SOUTH: { x: 0, y: 1 },
SOUTH_WEST: { x: -1, y: 1 },
WEST: { x: -1, y: 0 },
NORTH_WEST: { x: -1, y: -1 },
};

// Port of ig.getDirectionIndex — picks a tileOffsets slot from a face vector + dir count.
export function getDirectionIndex(faceX: number, faceY: number, numDirs: number): number {
switch (numDirs) {
case 1:
return 0;
case 2:
return faceX >= 0 ? 0 : 1;
case 4:
return Math.abs(faceY) > Math.abs(faceX)
? (faceY < 0 ? 0 : 2)
: (faceX > 0 ? 1 : 3);
case 6:
return faceX >= 0
? (faceY <= 0
? 0 + (57 * faceX > -100 * faceY ? 1 : 0)
: 1 + (57 * faceX < 100 * faceY ? 1 : 0))
: (faceY <= 0
? 4 + (-57 * faceX < -100 * faceY ? 1 : 0)
: 3 + (-57 * faceX > 100 * faceY ? 1 : 0));
case 8:
return Math.abs(faceY) > 2.414 * Math.abs(faceX)
? (faceY < 0 ? 0 : 4)
: Math.abs(faceX) > 2.414 * Math.abs(faceY)
? (faceX > 0 ? 2 : 6)
: (faceX > 0 ? (faceY < 0 ? 1 : 3) : (faceY > 0 ? 5 : 7));
default:
return Math.floor(numDirs / 2);
}
}

export function resolveDirIndex(anims: Anims, face: string | undefined, animName?: string): number | undefined {
const vec = face ? FACE_VECTORS[face] : undefined;
if (!vec) {
return undefined;
}
const leaves = flattenSUBs(anims, {});
const hasDirs = (leaf: Anims) => Array.isArray(leaf.tileOffsets) && leaf.tileOffsets.length > 0;
const leaf = (animName ? leaves.find(l => l.name === animName) : undefined) ?? leaves.find(hasDirs) ?? leaves[0];
if (!leaf) {
return undefined;
}
let numDirs = typeof leaf.dirs === 'string' ? parseInt(leaf.dirs, 10) : leaf.dirs;
if (!numDirs && Array.isArray(leaf.tileOffsets)) {
numDirs = leaf.tileOffsets.length;
}
if (!numDirs) {
return undefined;
}
return getDirectionIndex(vec.x, vec.y, numDirs);
}
64 changes: 2 additions & 62 deletions webapp/src/app/services/phaser/entities/registry/enemy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Fix } from '../cc-entity';
import { Helper } from '../../helper';
import { Anims, AnimSheet, Effect, flattenSUBs } from '../../sheet-parser';
import { DefaultEntity } from './default-entity';
import { resolveDirIndex } from './direction';

interface MultiEntityAnim extends Anims {
anims: Record<string, EntityAnim>;
Expand All @@ -27,48 +28,6 @@ interface EntityPart {
size: Point3;
}

// Unit face vectors (screen coords: +y points down, so NORTH is -y). Mirrors CC's FACE8.
const FACE_VECTORS: Record<string, { x: number; y: number }> = {
NORTH: { x: 0, y: -1 },
NORTH_EAST: { x: 1, y: -1 },
EAST: { x: 1, y: 0 },
SOUTH_EAST: { x: 1, y: 1 },
SOUTH: { x: 0, y: 1 },
SOUTH_WEST: { x: -1, y: 1 },
WEST: { x: -1, y: 0 },
NORTH_WEST: { x: -1, y: -1 },
};

// Port of ig.getDirectionIndex — picks a tileOffsets slot from a face vector + dir count.
function getDirectionIndex(faceX: number, faceY: number, numDirs: number): number {
switch (numDirs) {
case 1:
return 0;
case 2:
return faceX >= 0 ? 0 : 1;
case 4:
return Math.abs(faceY) > Math.abs(faceX)
? (faceY < 0 ? 0 : 2)
: (faceX > 0 ? 1 : 3);
case 6:
return faceX >= 0
? (faceY <= 0
? 0 + (57 * faceX > -100 * faceY ? 1 : 0)
: 1 + (57 * faceX < 100 * faceY ? 1 : 0))
: (faceY <= 0
? 4 + (-57 * faceX < -100 * faceY ? 1 : 0)
: 3 + (-57 * faceX > 100 * faceY ? 1 : 0));
case 8:
return Math.abs(faceY) > 2.414 * Math.abs(faceX)
? (faceY < 0 ? 0 : 4)
: Math.abs(faceX) > 2.414 * Math.abs(faceY)
? (faceX > 0 ? 2 : 6)
: (faceX > 0 ? (faceY < 0 ? 1 : 3) : (faceY > 0 ? 5 : 7));
default:
return Math.floor(numDirs / 2);
}
}

export interface EnemyAttributes {
enemyInfo?: EnemyInfo;
spawnCondition?: string;
Expand Down Expand Up @@ -140,29 +99,10 @@ export class Enemy extends DefaultEntity {
animName: 'idle',
label: settings.enemyInfo.type,
baseSize: enemyData.size,
dirIndex: this.resolveDirIndex(rawSheet, settings.enemyInfo.face),
dirIndex: resolveDirIndex(rawSheet, settings.enemyInfo.face),
});
}

private resolveDirIndex(anims: Anims, face: string | undefined): number | undefined {
// Find the numDirs used by this anim tree: first tileOffsets array encountered.
let numDirs = 0;
for (const leaf of flattenSUBs(anims, {})) {
if (Array.isArray(leaf.tileOffsets) && leaf.tileOffsets.length > 0) {
numDirs = leaf.tileOffsets.length;
break;
}
}
if (!numDirs) {
return undefined;
}
const vec = face ? FACE_VECTORS[face] : undefined;
if (!vec) {
return undefined;
}
return getDirectionIndex(vec.x, vec.y, numDirs);
}

private renderMultiEntity(animation: MultiEntityAnim, baseSize: Point3): boolean {
const anim = animation.anims['idle']
?? animation.anims['default']
Expand Down
177 changes: 4 additions & 173 deletions webapp/src/app/services/phaser/entities/registry/npc-templates.ts
Original file line number Diff line number Diff line change
@@ -1,176 +1,7 @@
import { Anims, SubJsonParam } from '../../sheet-parser';
import { Configs, Face, WalkAnimSet } from './npc';
import { Globals } from '../../../globals';

export interface NPCTemplates {
[key: string]: NPCTemplate;
}

export interface NPCTemplate {
name: SubJsonParam;
gender: SubJsonParam;
animSheet: Anims;
walkAnimSet: WalkAnimSet;
walkAnims: string;
configs: Configs;
face: Face;
realname?: SubJsonParam;
}
export type NPCTemplates = typeof import('../../../../../assets/json-templates.json');

export function getNPCTemplates(): NPCTemplates {
return {
NPCBasic: {
'name': {'jsonPARAM': 'name', 'default': null},
'gender': {'jsonPARAM': 'gender', 'default': null},
'animSheet': {
'DOCTYPE': 'MULTI_DIR_ANIMATION',
'namedSheets': {
// @ts-ignore
'move': {'src': {'jsonPARAM': 'img'}, 'width': 32, 'height': 40, 'xCount': 3, 'offX': {'jsonPARAM': 'x'}, 'offY': {'jsonPARAM': 'y'}},
// @ts-ignore
'sit': {'jsonIF': 'sitX', 'src': {'jsonPARAM': 'img'}, 'width': 32, 'height': 40, 'xCount': 1, 'offX': {'jsonPARAM': 'sitX'}, 'offY': {'jsonPARAM': 'sitY'}}
},
'shapeType': 'Y_FLAT',
'offset': {'x': 0, 'y': -2, 'z': 0},
'SUB': [
{
'sheet': 'move',
'dirs': 4,
'flipX': [0, 0, 0, 1],
'tileOffsets': [0, 3, 6, 3],
'SUB': [
{'name': 'idle', 'time': 1, 'repeat': false, 'frames': [1]},
{'name': 'walk', 'time': 0.133, 'repeat': true, 'frames': [0, 1, 2, 1]}
]
},
{
'jsonIF': 'sitX',
'sheet': 'sit',
'dirs': 4,
'flipX': [0, 0, 0, 1],
'tileOffsets': [0, 1, 2, 1],
'SUB': [
{'name': 'sit', 'time': 1, 'repeat': false, 'frames': [0]}
]
}
]
},
'walkAnimSet': {
'normal': {
'idle': 'idle',
'move': 'walk'
},
'sit': {
'jsonIF': 'sitX',
'idle': 'sit'
}
},
'walkAnims': 'normal',
'configs': {
'normal': {
'relativeVel': 0.5
},
'sit': {
'jsonIF': 'sitX',
'walkAnims': 'sit',
'shadow': 0
}
},
'face': {'ABSTRACT': {'jsonPARAM': 'face'}}
},
NPCAvatarSimple: {
'name': {'jsonPARAM': 'name', 'default': null},
'realname': {'jsonPARAM': 'realname', 'default': null},
'gender': {'jsonPARAM': 'gender', 'default': null},
'animSheet': {
'DOCTYPE': 'MULTI_DIR_ANIMATION',
'namedSheets': {
// @ts-ignore
'move': {'src': {'jsonPARAM': 'img'}, 'width': 32, 'height': 40, 'xCount': 3, 'offX': {'jsonPARAM': 'x'}, 'offY': {'jsonPARAM': 'y'}},
// @ts-ignore
'offline': {'src': {'jsonPARAM': 'img'}, 'width': 32, 'height': 40, 'xCount': 3, 'offX': {'jsonPARAM': 'offlineX'}, 'offY': {'jsonPARAM': 'offlineY'}},
// @ts-ignore
'run': {
'jsonIF': 'runSrc',
// @ts-ignore
'src': {'jsonPARAM': 'runSrc'},
'width': 32,
'height': 40,
'xCount': 5,
// @ts-ignore
'offX': {'jsonPARAM': 'runX'},
// @ts-ignore
'offY': {'jsonPARAM': 'runY'}
}
},
'shapeType': 'Y_FLAT',
'offset': {'x': 0, 'y': -2, 'z': 0},
'SUB': [
{
'sheet': 'move',
'dirs': 4,
'flipX': [0, 0, 0, 1],
'tileOffsets': [0, 3, 6, 3],
'SUB': [
{'name': 'idle', 'time': 1, 'repeat': false, 'frames': [1]},
{'name': 'walk', 'time': 0.133, 'repeat': true, 'frames': [0, 1, 2, 1]},
{'sheet': 'offline', 'name': 'offline', 'time': 0.166, 'repeat': true, 'frames': [0, 1, 2]}
]
},
{
'jsonIF': 'runSrc',
'sheet': 'run',
'dirs': 6,
'flipX': [0, 0, 0, 1, 1, 1],
'tileOffsets': [0, 5, 10, 10, 5, 0],
'SUB': [
{'name': 'run', 'time': 0.1, 'repeat': true, 'frames': [0, 1, 2, 3]},
{'name': 'jump', 'time': 0.1, 'repeat': true, 'frames': [3]},
{'name': 'fall', 'time': 0.1, 'repeat': true, 'frames': [4]}
]
},
{
'sheet': 'move',
'dirs': 2,
'flipX': [0, 1],
'tileOffsets': [9, 9],
'SUB': [
{'name': 'ground', 'time': 1, 'repeat': false, 'frames': [0], 'offset': {'x': 0, 'y': 2, 'z': 0}}
]
}
]
},
'walkAnimSet': {
'normal': {
'idle': 'idle',
'move': 'walk',
'run': {'jsonIF': 'runSrc', 'jsonTHEN': 'run'},
'jump': {'jsonIF': 'runSrc', 'jsonTHEN': 'jump'},
'fall': {'jsonIF': 'runSrc', 'jsonTHEN': 'fall'}
},
'ground': {
'idle': 'ground'
},
'offline': {
'idle': 'offline'
}
},
'walkAnims': 'normal',
'configs': {
'normal': {
'relativeVel': 0.5
},
'run': {
'jsonIF': 'runSrc',
'relativeVel': 1
},
'ground': {
'walkAnims': 'ground'
},
'offline': {
'walkAnims': 'offline'
}
},
'face': {'ABSTRACT': {'jsonPARAM': 'face'}}
}
};
export function getNPCTemplates(): Promise<NPCTemplates> {
return Globals.jsonLoader.loadJsonMerged<NPCTemplates>('json-templates.json');
}
Loading
Loading