refactor: 新的筛选算法

This commit is contained in:
unanmed 2026-03-30 12:35:08 +08:00
parent c64a783d5e
commit ef79dd4d31
8 changed files with 1063 additions and 139 deletions

View File

@ -254,14 +254,16 @@ const labelConfig: IAutoLabelConfig = {
entry: 10
},
allowedSize: [[13, 13]],
allowUselessBranch: true,
maxWallDensityStd: 0.23,
allowUselessBranch: false,
maxWallDensityStd: 1,
maxEmptyArea: 8,
maxResourceArea: 8,
minEnemyRatio: 0.02,
maxEnemyRatio: 0.3,
minWallRatio: 0.2,
maxWallRatio: 0.6,
minResourceRatio: 0.02,
maxResourceRatio: 0.3,
minResourceRatio: 0.05,
maxResourceRatio: 0.25,
minDoorRatio: 0,
maxDoorRatio: 0.12,
minFishCount: 0,
@ -269,15 +271,15 @@ const labelConfig: IAutoLabelConfig = {
minEntryCount: 1,
maxEntryCount: 4,
guassainRadius: 0,
heatmapKernel: 0,
heatmapKernel: 1,
ignoreIssues: true,
customTowerFilter: info => {
// if (info.name !== 'Apeiria') {
// return false;
// }
if (info.color !== TowerColor.Blue && info.color !== TowerColor.Green) {
return false;
}
// if (info.color !== TowerColor.Blue && info.color !== TowerColor.Green) {
// return false;
// }
if (info.people < 1000) {
return false;
}
@ -290,26 +292,26 @@ const labelConfig: IAutoLabelConfig = {
if (info.name.startsWith('24') && info.name.length > 2) {
return false;
}
if (ignoredTower.includes(info.name)) {
return false;
}
// if (ignoredTower.includes(info.name)) {
// return false;
// }
return true;
},
customFloorFilter: floor => {
if (floor.info.topo.graphs.length > 1) {
if (floor.info.topo.graph.areas.size > 1) {
return false;
}
if (floor.data.hasCannotInOut) {
return false;
}
if (floor.info.topo.unreachable.size > 0) {
if (floor.info.topo.graph.unreachableArea.size > 0) {
return false;
}
if (ignoredFloor[floor.tower.name]?.includes(floor.mapId)) {
return false;
}
if (floor.tower.name === 'Apeiria') {
return Math.random() < 0.2;
return Math.random() < 0.1;
}
return true;
}

View File

@ -4,6 +4,7 @@ import { IAutoLabelConfig, IConvertedMapInfo, ITowerInfo } from './types';
import { join } from 'path';
import { Presets, SingleBar } from 'cli-progress';
import { convertTowerMap, runTowerCode } from './tower';
import { MapTileConverter } from './converter';
export interface ILabelResult {
/** 塔信息列表 */
@ -31,6 +32,8 @@ export async function autoLabelTowers(
// 统计被不同规则过滤掉的楼层
let ignoredFloorsSize = 0;
let ignoredMaxEmptyArea = 0;
let ignoredMaxResourceArea = 0;
let ignoredFloorsEnemy = 0;
let ignoredFloorsWall = 0;
let ignoredFloorsResource = 0;
@ -76,6 +79,8 @@ export async function autoLabelTowers(
continue;
}
const converter = new MapTileConverter(result, config);
const info = towers.get(result.data.firstData.name);
if (!info) continue;
const customPass = config.customTowerFilter?.(info) ?? true;
@ -94,8 +99,29 @@ export async function autoLabelTowers(
continue;
}
// 转换楼层
const converted = convertTowerMap(result, floor, config);
const floorInfo = parseFloorInfo(info, converted.map, config);
const converted = convertTowerMap(result, floor, config, converter);
const otherLayers = [];
if (floor.bgmap) {
otherLayers.push(floor.bgmap);
}
if (floor.bg2map) {
otherLayers.push(floor.bg2map);
}
if (floor.fgmap) {
otherLayers.push(floor.fgmap);
}
if (floor.fg2map) {
otherLayers.push(floor.fg2map);
}
const floorInfo = parseFloorInfo(
info,
floor.map,
converted.map,
otherLayers,
config,
converter,
name
);
const floorData: IConvertedMapInfo = {
data: converted,
tower: info,
@ -103,6 +129,14 @@ export async function autoLabelTowers(
info: floorInfo
};
// 配置过滤楼层
if (floorInfo.maxEmptyArea > config.maxEmptyArea) {
ignoredMaxEmptyArea++;
continue;
}
if (floorInfo.maxResourceArea > config.maxResourceArea) {
ignoredMaxResourceArea++;
continue;
}
if (
floorInfo.enemyDensity < config.minEnemyRatio ||
floorInfo.enemyDensity > config.maxEnemyRatio
@ -131,13 +165,6 @@ export async function autoLabelTowers(
ignoredFloorsDoor++;
continue;
}
if (
floorInfo.fishCount < config.minFishCount ||
floorInfo.fishCount > config.maxFishCount
) {
ignoredFloorsFish++;
continue;
}
if (
floorInfo.entryCount < config.minEntryCount ||
floorInfo.entryCount > config.maxEntryCount
@ -191,6 +218,8 @@ export async function autoLabelTowers(
)} ${totalFilted} :`
);
console.log(`尺寸过滤:${ignoredFloorsSize}`);
console.log(`空地过滤:${ignoredMaxEmptyArea}`);
console.log(`资源区域过滤:${ignoredMaxResourceArea}`);
console.log(`怪物过滤:${ignoredFloorsEnemy}`);
console.log(`墙壁过滤:${ignoredFloorsWall}`);
console.log(`资源过滤:${ignoredFloorsResource}`);

343
data/src/auto/converter.ts Normal file
View File

@ -0,0 +1,343 @@
import { sum } from 'lodash-es';
import { IAutoLabelConfig, ICodeRunResult } from './types';
import { CannotInOut, IMapTileConverter, ResourceType } from './types';
export class MapTileConverter implements IMapTileConverter {
private readonly tower: ICodeRunResult;
private readonly config: IAutoLabelConfig;
private readonly noPassMap = new Map<number, boolean>();
private readonly cannotInMap = new Map<number, number>();
private readonly cannotOutMap = new Map<number, number>();
private readonly labelMap = new Map<number, number>();
private readonly resourceMap = new Map<number, Map<ResourceType, number>>();
private readonly emptyTiles = new Set<number>([0]);
private readonly doorTiles = new Set<number>();
private readonly enemyTiles = new Set<number>();
private readonly resourceTiles = new Set<number>();
private readonly keyTiles = new Set<number>();
private readonly itemTiles = new Set<number>();
constructor(tower: ICodeRunResult, config: IAutoLabelConfig) {
this.tower = tower;
this.config = config;
// 基于 maps 字典预计算各 tile 的分类与属性,避免重复解析。
const tileMap = tower.map ?? {};
for (const key of Object.keys(tileMap)) {
const tile = Number(key);
if (!Number.isFinite(tile)) continue;
this.precomputeTile(tile);
}
}
private static parseDirectionFlag(d: string | undefined): number {
if (!d) return 0;
switch (d) {
case 'left':
return CannotInOut.Left;
case 'right':
return CannotInOut.Right;
case 'up':
case 'top':
return CannotInOut.Top;
case 'down':
case 'bottom':
return CannotInOut.Bottom;
default:
return 0;
}
}
private parseFlags(arr?: string[]): number {
if (!arr || arr.length === 0) return 0;
let result = 0;
for (const d of arr) {
result |= MapTileConverter.parseDirectionFlag(d);
}
return result;
}
private normalizeResource(values: Partial<Record<ResourceType, number>>) {
const out = new Map<ResourceType, number>();
for (const [k, v] of Object.entries(values)) {
const n = Number(v);
if (!Number.isFinite(n) || n <= 0) continue;
out.set(Number(k) as ResourceType, n);
}
return out;
}
private evalItemEffectResource(
itemEffect?: string
): Map<ResourceType, number> {
if (!itemEffect) return new Map();
const heroStatus = {
hp: 0,
atk: 0,
def: 0,
mdef: 0
};
const thisMap = { ratio: 1 };
const values = this.tower.data?.values;
if (!values) return new Map();
const core = {
values: new Proxy(values, {
set() {
return true;
}
}),
status: {
hero: new Proxy(heroStatus, {
set(target, p: string, newValue) {
if (typeof newValue !== 'number') return true;
if (
p !== 'hp' &&
p !== 'atk' &&
p !== 'def' &&
p !== 'mdef'
) {
return true;
}
target[p] = newValue;
return true;
}
}),
thisMap: new Proxy(thisMap, {
set() {
return true;
}
})
}
};
const log = console.log;
console.log = () => {};
try {
eval(itemEffect);
} catch (e) {
console.log = log;
return new Map();
}
console.log = log;
return this.normalizeResource({
[ResourceType.Hp]: heroStatus.hp,
[ResourceType.Atk]: heroStatus.atk,
[ResourceType.Def]: heroStatus.def,
[ResourceType.Mdef]: heroStatus.mdef
});
}
private precomputeTile(tile: number): void {
if (this.labelMap.has(tile)) return;
const labels = this.config.classes;
const block = this.tower.map?.[tile];
if (this.emptyTiles.has(tile)) {
this.noPassMap.set(tile, false);
this.cannotInMap.set(tile, 0);
this.cannotOutMap.set(tile, 0);
this.labelMap.set(tile, labels.empty);
this.resourceMap.set(tile, new Map());
return;
}
if (!block || tile === 17) {
// 未知 tile 默认按墙处理
this.noPassMap.set(tile, true);
this.cannotInMap.set(tile, 0b1111);
this.cannotOutMap.set(tile, 0b1111);
this.labelMap.set(tile, labels.wall);
this.resourceMap.set(tile, new Map());
return;
}
const cannotIn = this.parseFlags(block.cannotIn);
const cannotOut = this.parseFlags(block.cannotOut);
this.cannotInMap.set(tile, cannotIn);
this.cannotOutMap.set(tile, cannotOut);
const blockId = block.id;
const cls = block.cls;
// 1. 钥匙资源识别doorInfo.keys
let isKey = false;
let keyCount = 0;
if (block.doorInfo && block.doorInfo.keys) {
const keys = block.doorInfo.keys;
keyCount = sum(Object.values(keys));
if (keyCount > 0) {
isKey = true;
this.keyTiles.add(tile);
}
}
// 2. 道具资源识别tools 且不是钥匙)
let isItem = false;
const item = this.tower.item?.[blockId];
if (item?.cls === 'tools') {
// 不是钥匙的 tools
if (!isKey) {
isItem = true;
this.itemTiles.add(tile);
}
}
const isDoor =
block.doorInfo ||
blockId.toLowerCase().endsWith('door') ||
blockId === 'specialDoor';
const isEnemy = cls === 'enemys' || cls === 'enemy48';
let isResource = false;
let resources = new Map<ResourceType, number>();
if (isKey) {
resources.set(ResourceType.Key, keyCount > 0 ? keyCount : 1);
isResource = true;
} else if (isItem) {
resources.set(ResourceType.Item, 1);
isResource = true;
} else if (cls === 'items') {
if (item?.cls === 'items') {
resources = this.evalItemEffectResource(item?.itemEffect);
isResource = resources.size > 0;
} else if (item?.cls === 'equip') {
if (item?.equip?.value) {
resources = this.normalizeResource({
[ResourceType.Hp]: item.equip.value.hp,
[ResourceType.Atk]: item.equip.value.atk,
[ResourceType.Def]: item.equip.value.def,
[ResourceType.Mdef]: item.equip.value.mdef
});
isResource = resources.size > 0;
}
}
}
const isEmpty =
!isDoor && !isEnemy && !isResource && block.canPass === true;
if (isDoor) {
this.doorTiles.add(tile);
this.noPassMap.set(tile, false);
const label = labels.commonDoors[0];
this.labelMap.set(tile, label);
} else if (isEnemy) {
this.enemyTiles.add(tile);
this.noPassMap.set(tile, false);
this.labelMap.set(tile, labels.enemies[0]);
} else if (isResource) {
this.resourceTiles.add(tile);
this.noPassMap.set(tile, false);
this.resourceMap.set(tile, resources);
let label = labels.items[0] ?? labels.empty;
const hp = resources.get(ResourceType.Hp) ?? 0;
const atk = resources.get(ResourceType.Atk) ?? 0;
const def = resources.get(ResourceType.Def) ?? 0;
const mdef = resources.get(ResourceType.Mdef) ?? 0;
const key = resources.get(ResourceType.Key) ?? 0;
const item = resources.get(ResourceType.Item) ?? 0;
const max = Math.max(hp, atk, def, mdef, key, item);
if (max > 0) {
if (max === hp) {
label = labels.potions[0] ?? label;
} else if (max === atk) {
label = labels.redGems[0] ?? label;
} else if (max === def) {
label = labels.blueGems[0] ?? label;
} else if (max === mdef) {
label = labels.greenGems[0] ?? label;
} else if (max === key) {
label = labels.keys[0] ?? label;
} else if (max === item) {
label = labels.items[0] ?? label;
}
}
this.labelMap.set(tile, label);
} else if (isEmpty) {
this.noPassMap.set(tile, false);
this.labelMap.set(tile, labels.empty);
} else if (block.canPass) {
this.noPassMap.set(tile, false);
this.labelMap.set(tile, labels.empty);
} else {
this.noPassMap.set(tile, true);
this.labelMap.set(tile, labels.empty);
}
if (!this.resourceMap.has(tile)) {
this.resourceMap.set(tile, new Map());
}
}
getLabeledTile(tile: number): number {
this.precomputeTile(tile);
return this.labelMap.get(tile) ?? this.config.classes.wall;
}
isEmpty(tile: number): boolean {
this.precomputeTile(tile);
return this.labelMap.get(tile) === this.config.classes.empty;
}
isEntry(tile: number, x: number, y: number, floorId: string): boolean {
const loc = `${x},${y}`;
if (this.tower.main.floors[floorId].changeFloor[loc]) {
return true;
} else {
return false;
}
}
isDoor(tile: number): boolean {
this.precomputeTile(tile);
return this.doorTiles.has(tile);
}
isEnemy(tile: number): boolean {
this.precomputeTile(tile);
return this.enemyTiles.has(tile);
}
isResource(tile: number): boolean {
this.precomputeTile(tile);
return (
this.resourceTiles.has(tile) ||
this.keyTiles.has(tile) ||
this.itemTiles.has(tile)
);
}
getNoPass(tile: number, x: number, y: number): boolean {
void x;
void y;
this.precomputeTile(tile);
return this.noPassMap.get(tile) ?? true;
}
getCannotIn(tile: number, x: number, y: number): number {
void x;
void y;
this.precomputeTile(tile);
return this.cannotInMap.get(tile) ?? 0;
}
getCannotOut(tile: number, x: number, y: number): number {
void x;
void y;
this.precomputeTile(tile);
return this.cannotOutMap.get(tile) ?? 0;
}
getResource(tile: number, x: number, y: number): Map<ResourceType, number> {
void x;
void y;
this.precomputeTile(tile);
return new Map(this.resourceMap.get(tile) ?? []);
}
}

View File

@ -8,7 +8,6 @@ export function generateHeatmap(
tokens: Set<number>,
kernel: number = 5
): number[][] {
if (kernel === 0) return map.map(v => v.slice());
if (kernel % 2 !== 1) {
throw new Error(`Kernal size must be odd.`);
}

View File

@ -1,8 +1,13 @@
import { readFile } from 'fs/promises';
import { IAutoLabelConfig, IFloorInfo, ITowerInfo, TowerColor } from './types';
import { buildTopologicalGraph } from '../topology/graph';
import {
commonDoorTiles,
GraphNodeType,
IAutoLabelConfig,
IFloorInfo,
IMapTileConverter,
ITowerInfo,
TowerColor
} from './types';
import {
doorTiles,
enemyTiles,
entryTiles,
@ -15,8 +20,8 @@ import {
specialDoorTiles,
wallTiles
} from '../shared';
import { NodeType } from '../topology/interface';
import { gaussainHeatmap, generateHeatmap } from './heatmap';
import { MapTopology } from './topo';
interface IRawTowerInfo {
/** 作者 id */
@ -155,62 +160,71 @@ export function computeWallDensityStd(
*/
export function parseFloorInfo(
tower: ITowerInfo,
originMap: number[][],
map: number[][],
config: IAutoLabelConfig
otherLayers: number[][][],
config: IAutoLabelConfig,
converter: IMapTileConverter,
floorId: string
): IFloorInfo {
const topo = buildTopologicalGraph(map);
const topo = new MapTopology(
floorId,
originMap,
map,
otherLayers,
converter,
config.classes
);
const flattened = map.flat();
const area = flattened.length;
let hasUselessBranch = false;
// 统计咸鱼门数量
let fishCount = 0;
topo.graphs.forEach(graph => {
// 其实就是判断纯血瓶钥匙的资源节点的邻居是不是全都是门,是的话就判定为咸鱼门
// 这么做虽然会有一定的误差,但是也大差不差了
// 两个门对一个也判定为一个咸鱼门
graph.areaMap.forEach(v => {
const res = [...v.resources.entries()];
const onlyPotion = res.every(([tile, value]) => {
if (!potionTiles.has(tile) && !keyTiles.has(tile)) {
return value <= 0;
}
return true;
});
if (!onlyPotion) {
// 包含血瓶钥匙之外的不考虑
return;
}
let branchCount = 0;
let noneBranchCount = 0;
v.neighbor.forEach(value => {
const node = graph.graph.get(value);
if (!node) {
noneBranchCount++;
return;
}
if (node.type === NodeType.Branch) {
if (!commonDoorTiles.has(node.tile)) {
branchCount++;
// 统计拓扑图信息
let maxEmptyArea = 0;
let maxResourceArea = 0;
topo.graph.areas.forEach(area => {
area.nodes.forEach(v => {
if (v.type === GraphNodeType.Empty) {
let branchConnection = 0;
v.neighbors.forEach(v => {
// 对节点的每个邻居遍历,如果邻居是分支节点,且直接相连的分支节点数小于 2
// 说明这个连接可能会导致无用节点
// 至于为什么要多一次额外的邻居节点判断:
// |---|---|---|---|---|
// | W | W | D | W | W |
// |---|---|---|---|---|
// | W | | E | | W |
// |---|---|---|---|---|
// | W | W | D | W | W |
// |---|---|---|---|---|
if (v.type === GraphNodeType.Branch) {
let directBranch = 0;
for (const n of v.neighbors) {
if (n.type === GraphNodeType.Branch) {
directBranch++;
}
}
if (directBranch < 2) {
branchConnection++;
}
}
} else {
noneBranchCount++;
}
});
if (noneBranchCount >= 0 && branchCount === 0) {
fishCount++;
}
});
graph.graph.forEach(v => {
if (v.type === NodeType.Branch) {
if (v.neighbor.size === 1) {
});
// 如果连接的分支数与邻居数相同,且小于等于 0说明是门或怪物后面连接了一整片空地是无用分支
// 如果连接的分支数与邻居数不相同,说明可能连接了资源节点、入口节点等,这些显然不应该算入无用分支
if (
branchConnection <= 1 &&
v.neighbors.size === branchConnection
) {
hasUselessBranch = true;
}
if (v.tiles.size > maxEmptyArea) {
maxEmptyArea = v.tiles.size;
}
} else if (v.type === GraphNodeType.Resource) {
if (v.tiles.size > maxResourceArea) {
maxResourceArea = v.tiles.size;
}
}
});
});
@ -219,6 +233,9 @@ export function parseFloorInfo(
tower,
topo,
map,
maxEmptyArea,
maxResourceArea,
hasUselessBranch,
globalDensity: count(flattened, nonEmptyTiles) / area,
wallDensity: count(flattened, wallTiles) / area,
doorDensity: count(flattened, doorTiles) / area,
@ -230,8 +247,6 @@ export function parseFloorInfo(
itemDensity: count(flattened, itemTiles) / area,
entryCount: count(flattened, entryTiles),
specialDoorCount: count(flattened, specialDoorTiles),
fishCount,
hasUselessBranch,
wallDensityStd: computeWallDensityStd(map, wallTiles, 5),
wallHeatmap: gaussainHeatmap(
generateHeatmap(map, wallTiles, config.heatmapKernel),

397
data/src/auto/topo.ts Normal file
View File

@ -0,0 +1,397 @@
import {
CannotInOut,
GraphNodeType,
BranchType,
ResourceType,
type IMapTopology,
type IMapGraph,
type IMapGraphArea,
type MapGraphNode,
type IEntryMapGraphNode,
type IMapTileConverter,
IMapBlockConfig
} from './types';
/** [dx, dy, 离开方向标记, 进入方向标记] */
const dirs: [number, number, CannotInOut, CannotInOut][] = [
[-1, 0, CannotInOut.Left, CannotInOut.Right],
[1, 0, CannotInOut.Right, CannotInOut.Left],
[0, -1, CannotInOut.Top, CannotInOut.Bottom],
[0, 1, CannotInOut.Bottom, CannotInOut.Top]
];
const ALL_BLOCKED =
CannotInOut.Left | CannotInOut.Top | CannotInOut.Right | CannotInOut.Bottom;
export class MapTopology implements IMapTopology {
readonly originMap: number[][];
readonly otherLayersMap: number[][][];
readonly convertedMap: number[][];
readonly noPass: boolean[][];
readonly cannotIn: number[][];
readonly cannotOut: number[][];
readonly graph: IMapGraph;
constructor(
readonly floorId: string,
map: number[][],
convertedMap: number[][],
otherLayers: number[][][],
converter: IMapTileConverter,
readonly config: IMapBlockConfig
) {
this.originMap = map;
this.otherLayersMap = otherLayers;
this.convertedMap = convertedMap;
const height = map.length;
const width = height > 0 ? map[0].length : 0;
this.noPass = map.map((row, y) =>
row.map((tile, x) => converter.getNoPass(tile, x, y))
);
this.cannotIn = map.map((row, y) =>
row.map((tile, x) => {
if (this.noPass[y][x]) return ALL_BLOCKED;
let flags = converter.getCannotIn(tile, x, y);
for (const layer of otherLayers) {
flags |= converter.getCannotIn(layer[y]?.[x] ?? 0, x, y);
}
return flags;
})
);
this.cannotOut = map.map((row, y) =>
row.map((tile, x) => {
if (this.noPass[y][x]) return ALL_BLOCKED;
let flags = converter.getCannotOut(tile, x, y);
for (const layer of otherLayers) {
flags |= converter.getCannotOut(layer[y]?.[x] ?? 0, x, y);
}
return flags;
})
);
this.graph = this.buildGraph(width, height, converter);
}
private buildGraph(
width: number,
height: number,
converter: IMapTileConverter
): IMapGraph {
const size = width * height;
// 1. 使用 converter 对每个图块进行分类
const tileType = new Array<GraphNodeType | null>(size).fill(null);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const tile = this.convertedMap[y][x];
const origin = this.originMap[y][x];
const idx = y * width + x;
if (tile === this.config.wall) {
tileType[idx] = GraphNodeType.Wall;
} else if (
tile === this.config.entry ||
converter.isEntry(origin, x, y, this.floorId)
) {
tileType[idx] = GraphNodeType.Entry;
} else if (
this.config.enemies.includes(tile) ||
converter.isEnemy(origin)
) {
tileType[idx] = GraphNodeType.Branch;
} else if (
this.config.commonDoors.includes(tile) ||
this.config.specialDoors.includes(tile) ||
converter.isDoor(origin)
) {
tileType[idx] = GraphNodeType.Branch;
} else if (
this.config.potions.includes(tile) ||
this.config.redGems.includes(tile) ||
this.config.blueGems.includes(tile) ||
this.config.greenGems.includes(tile) ||
this.config.items.includes(tile) ||
this.config.keys.includes(tile) ||
converter.isResource(origin)
) {
tileType[idx] = GraphNodeType.Resource;
} else if (
tile === this.config.empty ||
converter.isEmpty(origin)
) {
tileType[idx] = GraphNodeType.Empty;
} else {
tileType[idx] = GraphNodeType.Wall;
}
}
}
// 2. 通过 BFS 将图块分组为节点
// 空白和资源节点:相邻同类型的图块合并为一个节点
// 分支和入口节点:每个图块独立为一个节点
const nodeMap = new Map<number, MapGraphNode>();
const visited = new Set<number>();
let nodeIndex = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (visited.has(idx)) continue;
visited.add(idx);
const type = tileType[idx]!;
if (type === GraphNodeType.Wall) continue;
const tiles = new Set<number>([idx]);
const neighbors = new Set<MapGraphNode>();
// 分支和入口节点不合并,每个图块独立为一个节点
if (type === GraphNodeType.Entry) {
nodeMap.set(idx, {
type: GraphNodeType.Entry,
index: nodeIndex++,
tiles,
neighbors
});
continue;
}
if (type === GraphNodeType.Branch) {
const tile = this.originMap[y][x];
nodeMap.set(idx, {
type: GraphNodeType.Branch,
index: nodeIndex++,
tiles,
neighbors,
branch: converter.isDoor(tile)
? BranchType.Door
: BranchType.Enemy
});
continue;
}
// 空白和资源节点BFS 合并相邻同类型图块
const queue: number[] = [idx];
while (queue.length > 0) {
const ci = queue.shift()!;
const cx = ci % width;
const cy = (ci - cx) / width;
for (const [dx, dy, outFlag, inFlag] of dirs) {
const nx = cx + dx;
const ny = cy + dy;
if (nx < 0 || nx >= width || ny < 0 || ny >= height)
continue;
const ni = ny * width + nx;
if (visited.has(ni) || tileType[ni] !== type) continue;
// 任一方向可通行即可合并
const canGo =
!(this.cannotOut[cy][cx] & outFlag) &&
!(this.cannotIn[ny][nx] & inFlag);
const canCome =
!(this.cannotOut[ny][nx] & inFlag) &&
!(this.cannotIn[cy][cx] & outFlag);
if (false) continue;
// if (!canGo && !canCome) continue;
visited.add(ni);
tiles.add(ni);
queue.push(ni);
}
}
let node: MapGraphNode;
if (type === GraphNodeType.Empty) {
node = {
type: GraphNodeType.Empty,
index: nodeIndex++,
tiles,
neighbors
};
} else {
const resources = new Map<ResourceType, number>();
for (const t of tiles) {
const tx = t % width;
const ty = (t - tx) / width;
const res = converter.getResource(
this.originMap[ty][tx],
tx,
ty
);
for (const [k, v] of res) {
resources.set(k, (resources.get(k) ?? 0) + v);
}
}
node = {
type: GraphNodeType.Resource,
index: nodeIndex++,
tiles,
neighbors,
resources
};
}
for (const t of tiles) {
nodeMap.set(t, node);
}
}
}
// 3. 构建节点间的邻接关系
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const node = nodeMap.get(y * width + x);
if (!node) continue;
for (const [dx, dy, outFlag, inFlag] of dirs) {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || nx >= width || ny < 0 || ny >= height)
continue;
const neighbor = nodeMap.get(ny * width + nx);
if (!neighbor || neighbor === node) continue;
// 至少一个方向可通行则建立邻接关系
const canGo =
!(this.cannotOut[y][x] & outFlag) &&
!(this.cannotIn[ny][nx] & inFlag);
const canCome =
!(this.cannotOut[ny][nx] & inFlag) &&
!(this.cannotIn[y][x] & outFlag);
// if (canGo || canCome) {
if (true) {
node.neighbors.add(neighbor);
neighbor.neighbors.add(node);
}
}
}
}
// 4. 通过 BFS 连通分量构建区域
const areas = new Set<IMapGraphArea>();
const unreachableArea = new Set<IMapGraphArea>();
const entries = new Set<IEntryMapGraphNode>();
const visitedNodes = new Set<MapGraphNode>();
for (const node of new Set(nodeMap.values())) {
if (visitedNodes.has(node)) continue;
visitedNodes.add(node);
const areaNodes = new Set<MapGraphNode>();
const areaEntries: IEntryMapGraphNode[] = [];
const queue: MapGraphNode[] = [node];
while (queue.length > 0) {
const current = queue.shift()!;
if (areaNodes.has(current)) continue;
areaNodes.add(current);
visitedNodes.add(current);
if (current.type === GraphNodeType.Entry) {
areaEntries.push(current);
}
for (const nb of current.neighbors) {
if (!visitedNodes.has(nb)) {
visitedNodes.add(nb);
queue.push(nb);
}
}
}
const area: IMapGraphArea = { nodes: areaNodes };
areas.add(area);
if (areaEntries.length > 0) {
for (const e of areaEntries) {
entries.add(e);
}
} else {
unreachableArea.add(area);
}
}
// console.log(areas.size);
return { unreachableArea, areas, entries, nodeMap };
}
private resolveIgnored(
ignoredNode?: (MapGraphNode | number)[]
): Set<MapGraphNode> {
const ignored = new Set<MapGraphNode>();
if (!ignoredNode) return ignored;
for (const item of ignoredNode) {
if (typeof item === 'number') {
const node = this.graph.nodeMap.get(item);
if (node) ignored.add(node);
} else {
ignored.add(item);
}
}
return ignored;
}
connectedToAnyEntry(
pos: number,
ignoredNode?: (MapGraphNode | number)[]
): boolean {
const startNode = this.graph.nodeMap.get(pos);
if (!startNode) return false;
if (startNode.type === GraphNodeType.Entry) return true;
const ignored = this.resolveIgnored(ignoredNode);
if (ignored.has(startNode)) return false;
const visited = new Set<MapGraphNode>([startNode]);
const queue: MapGraphNode[] = [startNode];
while (queue.length > 0) {
const current = queue.shift()!;
for (const nb of current.neighbors) {
if (visited.has(nb) || ignored.has(nb)) continue;
if (nb.type === GraphNodeType.Entry) return true;
visited.add(nb);
queue.push(nb);
}
}
return false;
}
connectedToSpecificEntry(
pos: number,
entry: number | IEntryMapGraphNode,
ignoredNode?: (MapGraphNode | number)[]
): boolean {
const startNode = this.graph.nodeMap.get(pos);
if (!startNode) return false;
const targetNode =
typeof entry === 'number' ? this.graph.nodeMap.get(entry) : entry;
if (!targetNode) return false;
if (startNode === targetNode) return true;
const ignored = this.resolveIgnored(ignoredNode);
if (ignored.has(startNode)) return false;
const visited = new Set<MapGraphNode>([startNode]);
const queue: MapGraphNode[] = [startNode];
while (queue.length > 0) {
const current = queue.shift()!;
for (const nb of current.neighbors) {
if (visited.has(nb) || ignored.has(nb)) continue;
if (nb === targetNode) return true;
visited.add(nb);
queue.push(nb);
}
}
return false;
}
}

View File

@ -1,3 +1,4 @@
import { IMapTileConverter, ResourceType } from './types';
import {
IAutoLabelConfig,
ICodeRunResult,
@ -26,6 +27,8 @@ export function runTowerCode(project: string, floors: string): ICodeRunResult {
result.item = items_296f5d02_12fd_4166_a7c1_b5e830c9ee3a;
`;
const main = result.main!;
const log = console.log;
console.log = () => {};
try {
eval(projectCode);
eval(floors);
@ -35,17 +38,15 @@ export function runTowerCode(project: string, floors: string): ICodeRunResult {
} catch {
result.issue?.push(`代码运行错误`);
}
console.log = log;
return result as ICodeRunResult;
}
function edge(x: number, y: number, width: number, height: number) {
return x === 0 || y === 0 || x === width - 1 || y === height - 1;
}
export function convertTowerMap(
result: ICodeRunResult,
floor: INeededFloorData,
config: IAutoLabelConfig
config: IAutoLabelConfig,
converter: IMapTileConverter
): IConvertedMap {
const width = floor.map[0].length;
const height = floor.map.length;
@ -73,48 +74,6 @@ export function convertTowerMap(
mdef: 0
};
const thisMap = {
ratio: 1
};
// 给后面的 eval 用的
const core = {
values: new Proxy(result.data.values, {
set() {
// 防止被修改
return true;
}
}),
status: {
hero: new Proxy(heroStatus, {
set(target, p: string, newValue) {
if (typeof newValue !== 'number') return true;
if (
p !== 'hp' &&
p !== 'atk' &&
p !== 'def' &&
p !== 'mdef'
) {
return true;
}
target[p] = newValue;
return true;
}
}),
thisMap: new Proxy(thisMap, {
set() {
// 防止被修改
return true;
}
})
}
};
core.status.hero.hp = 0;
core.status.hero.atk = 0;
core.status.hero.def = 0;
core.status.hero.mdef = 0;
const tiles = config.classes;
for (let nx = 0; nx < width; nx++) {
@ -188,17 +147,11 @@ export function convertTowerMap(
continue;
}
// 执行道具效果
if (item.cls === 'items' && item.itemEffect) {
try {
eval(item.itemEffect);
} catch {
// 执行失败就清空一下防止被误识别为宝石血瓶
heroStatus.hp = 0;
heroStatus.atk = 0;
heroStatus.def = 0;
heroStatus.mdef = 0;
}
}
const effect = converter.getResource(num, nx, ny);
heroStatus.hp = effect.get(ResourceType.Hp) ?? 0;
heroStatus.atk = effect.get(ResourceType.Atk) ?? 0;
heroStatus.def = effect.get(ResourceType.Def) ?? 0;
heroStatus.mdef = effect.get(ResourceType.Mdef) ?? 0;
const arr: [number, number, number, number] = [
heroStatus.hp,
heroStatus.atk,

View File

@ -1,4 +1,4 @@
import { GinkaTopologicalGraphs } from 'src/topology/interface';
import { GinkaTopologicalGraphs } from '../topology/interface';
export const enum TowerColor {
White,
@ -62,7 +62,11 @@ export interface IFloorInfo {
/** 楼层所属的塔信息 */
readonly tower: ITowerInfo;
/** 楼层拓扑图 */
readonly topo: GinkaTopologicalGraphs;
readonly topo: IMapTopology;
/** 最大空地面积 */
readonly maxEmptyArea: number;
/** 最大资源区域面积 */
readonly maxResourceArea: number;
/** 地图矩阵 */
readonly map: number[][];
/** 地图整体密度,非空白图块/地图面积 */
@ -87,9 +91,7 @@ export interface IFloorInfo {
readonly entryCount: number;
/** 机关门数量 */
readonly specialDoorCount: number;
/** 咸鱼门数量,多层咸鱼门算一个 */
readonly fishCount: number;
/** 是否包含只连接了一个节点的分支节点。这种节点相当于门或怪物后面什么都不加,多数是无用的。 */
/** 是否包含只连接了一个节点的空白节点。这种节点相当于门或怪物后面什么都不加,多数是无用的。 */
readonly hasUselessBranch: boolean;
/** 墙壁密度标准差 */
readonly wallDensityStd: number;
@ -183,6 +185,10 @@ export interface IAutoLabelConfig {
/** 最大墙壁密度标准差,用于描述一个地图墙壁分布是否均匀的,较大的时候可能是特殊地图,不符合要求 */
readonly maxWallDensityStd: number;
/** 最大空地区域面积,超过这个的地图会忽略 */
readonly maxEmptyArea: number;
/** 最大资源区域面积,超过这个的地图会忽略 */
readonly maxResourceArea: number;
/** 热力图统计算子 */
readonly heatmapKernel: number;
/** 热力图高斯模糊的标准差 */
@ -260,15 +266,195 @@ export interface INeededFloorData {
readonly fgmap?: number[][];
readonly fg2map?: number[][];
readonly changeFloor: Record<string, unknown>;
readonly events?: Record<string, any[] | { readonly noPass: boolean }>;
readonly cannotMove?: ('left' | 'right' | 'up' | 'down')[];
readonly cannotMoveIn?: ('left' | 'right' | 'up' | 'down')[];
}
export interface ICodeRunResult {
issue: string[];
data: INeededCoreData;
enemy: Record<string, INeededEnemyData>;
/** Tile 数字到其内容的映射 */
map: Record<number, INeededMapData>;
item: Record<string, INeededItemData>;
main: {
floors: Record<string, INeededFloorData>;
};
}
export const enum CannotInOut {
/** 左侧不可入 / 不可出 */
Left = 0b0001,
/** 上侧不可入 / 不可出 */
Top = 0b0010,
/** 右侧不可入 / 不可出 */
Right = 0b0100,
/** 下侧不可入 / 不可出 */
Bottom = 0b1000
}
export const enum GraphNodeType {
Empty,
/** 资源节点,由资源组成 */
Resource,
/** 分支节点,由门或怪物组成 */
Branch,
/** 入口节点 */
Entry,
/** 墙 */
Wall
}
export const enum ResourceType {
Hp,
Atk,
Def,
Mdef,
Item,
Key
}
export const enum BranchType {
Door,
Enemy
}
export interface IMapGraphNodeBase {
/** 节点类型 */
readonly type: GraphNodeType;
/** 当前节点在拓扑图中的索引 */
readonly index: number;
/** 此节点包含的所有地图坐标 */
readonly tiles: Set<number>;
/** 当前节点的邻居节点 */
readonly neighbors: Set<MapGraphNode>;
}
export interface IEmptyMapGraphNode extends IMapGraphNodeBase {
readonly type: GraphNodeType.Empty;
}
export interface IResourceMapGraphNode extends IMapGraphNodeBase {
readonly type: GraphNodeType.Resource;
/** 节点包含的资源数量 */
readonly resources: Map<ResourceType, number>;
}
export interface IBranchMapGraphNode extends IMapGraphNodeBase {
readonly type: GraphNodeType.Branch;
/** 分支节点类型 */
readonly branch: BranchType;
}
export interface IEntryMapGraphNode extends IMapGraphNodeBase {
readonly type: GraphNodeType.Entry;
}
export type MapGraphNode =
| IEmptyMapGraphNode
| IResourceMapGraphNode
| IBranchMapGraphNode
| IEntryMapGraphNode;
export interface IMapGraphArea {
/** 当前区域包含的所有节点 */
readonly nodes: Set<MapGraphNode>;
}
export interface IMapGraph {
/** 不可到达的区域 */
readonly unreachableArea: Set<IMapGraphArea>;
/** 当前拓扑图包含的区域信息 */
readonly areas: Set<IMapGraphArea>;
/** 当前拓扑图的所有入口 */
readonly entries: Set<IEntryMapGraphNode>;
/** 坐标至其所在节点的映射,可以根据坐标获取其对应的节点 */
readonly nodeMap: Map<number, MapGraphNode>;
}
export interface IMapTopology {
/** 原始地图 */
readonly originMap: number[][];
/** 事件层除外的层的地图 */
readonly otherLayersMap: number[][][];
/** 经过转换的地图 */
readonly convertedMap: number[][];
/** 不可通行标记 */
readonly noPass: boolean[][];
/** 不可入标记 */
readonly cannotIn: number[][];
/** 不可出标记 */
readonly cannotOut: number[][];
/** 地图的拓扑图 */
readonly graph: IMapGraph;
/**
* y * width + x
* @param pos
* @param ignoredNode
*/
connectedToAnyEntry(
pos: number,
ignoredNode?: (MapGraphNode | number)[]
): boolean;
/**
* y * width + x
* @param pos
* @param entry
* @param ignoredNode
*/
connectedToSpecificEntry(
pos: number,
entry: number | IEntryMapGraphNode,
ignoredNode?: (MapGraphNode | number)[]
): boolean;
}
export interface IMapTileConverter {
/**
*
* @param tile
*/
getLabeledTile(tile: number): number;
isEmpty(tile: number): boolean;
isEntry(tile: number, x: number, y: number, floorId: string): boolean;
isDoor(tile: number): boolean;
isEnemy(tile: number): boolean;
isResource(tile: number): boolean;
/**
*
* @param tile
* @param x
* @param y
*/
getNoPass(tile: number, x: number, y: number): boolean;
/**
*
* @param tile
* @param x
* @param y
*/
getCannotIn(tile: number, x: number, y: number): number;
/**
*
* @param tile
* @param x
* @param y
*/
getCannotOut(tile: number, x: number, y: number): number;
/**
*
*/
getResource(tile: number, x: number, y: number): Map<ResourceType, number>;
}