From ef79dd4d314eff7be70a23d0eb970e6138986500 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Mon, 30 Mar 2026 12:35:08 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=96=B0=E7=9A=84=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E7=AE=97=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/src/auto.ts | 30 +-- data/src/auto/auto.ts | 47 ++++- data/src/auto/converter.ts | 343 ++++++++++++++++++++++++++++++++ data/src/auto/heatmap.ts | 1 - data/src/auto/info.ts | 119 ++++++----- data/src/auto/topo.ts | 397 +++++++++++++++++++++++++++++++++++++ data/src/auto/tower.ts | 69 +------ data/src/auto/types.ts | 196 +++++++++++++++++- 8 files changed, 1063 insertions(+), 139 deletions(-) create mode 100644 data/src/auto/converter.ts create mode 100644 data/src/auto/topo.ts diff --git a/data/src/auto.ts b/data/src/auto.ts index 48155af..bae5855 100644 --- a/data/src/auto.ts +++ b/data/src/auto.ts @@ -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; } diff --git a/data/src/auto/auto.ts b/data/src/auto/auto.ts index 0cabafb..05c8139 100644 --- a/data/src/auto/auto.ts +++ b/data/src/auto/auto.ts @@ -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} 层`); diff --git a/data/src/auto/converter.ts b/data/src/auto/converter.ts new file mode 100644 index 0000000..bda544e --- /dev/null +++ b/data/src/auto/converter.ts @@ -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(); + private readonly cannotInMap = new Map(); + private readonly cannotOutMap = new Map(); + private readonly labelMap = new Map(); + private readonly resourceMap = new Map>(); + + private readonly emptyTiles = new Set([0]); + private readonly doorTiles = new Set(); + private readonly enemyTiles = new Set(); + private readonly resourceTiles = new Set(); + private readonly keyTiles = new Set(); + private readonly itemTiles = new Set(); + + 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>) { + const out = new Map(); + 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 { + 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(); + 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 { + void x; + void y; + this.precomputeTile(tile); + return new Map(this.resourceMap.get(tile) ?? []); + } +} diff --git a/data/src/auto/heatmap.ts b/data/src/auto/heatmap.ts index 0e049a7..8b965c9 100644 --- a/data/src/auto/heatmap.ts +++ b/data/src/auto/heatmap.ts @@ -8,7 +8,6 @@ export function generateHeatmap( tokens: Set, 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.`); } diff --git a/data/src/auto/info.ts b/data/src/auto/info.ts index 338019d..61c06ac 100644 --- a/data/src/auto/info.ts +++ b/data/src/auto/info.ts @@ -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), diff --git a/data/src/auto/topo.ts b/data/src/auto/topo.ts new file mode 100644 index 0000000..7fdd056 --- /dev/null +++ b/data/src/auto/topo.ts @@ -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(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(); + const visited = new Set(); + 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([idx]); + const neighbors = new Set(); + + // 分支和入口节点不合并,每个图块独立为一个节点 + 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(); + 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(); + const unreachableArea = new Set(); + const entries = new Set(); + const visitedNodes = new Set(); + + for (const node of new Set(nodeMap.values())) { + if (visitedNodes.has(node)) continue; + visitedNodes.add(node); + + const areaNodes = new Set(); + 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 { + const ignored = new Set(); + 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([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([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; + } +} diff --git a/data/src/auto/tower.ts b/data/src/auto/tower.ts index 5972348..12fc080 100644 --- a/data/src/auto/tower.ts +++ b/data/src/auto/tower.ts @@ -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, diff --git a/data/src/auto/types.ts b/data/src/auto/types.ts index 2be65e6..256f8f1 100644 --- a/data/src/auto/types.ts +++ b/data/src/auto/types.ts @@ -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; + readonly events?: Record; + readonly cannotMove?: ('left' | 'right' | 'up' | 'down')[]; + readonly cannotMoveIn?: ('left' | 'right' | 'up' | 'down')[]; } export interface ICodeRunResult { issue: string[]; data: INeededCoreData; enemy: Record; + /** Tile 数字到其内容的映射 */ map: Record; item: Record; main: { floors: Record; }; } + +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; + /** 当前节点的邻居节点 */ + readonly neighbors: Set; +} + +export interface IEmptyMapGraphNode extends IMapGraphNodeBase { + readonly type: GraphNodeType.Empty; +} + +export interface IResourceMapGraphNode extends IMapGraphNodeBase { + readonly type: GraphNodeType.Resource; + /** 节点包含的资源数量 */ + readonly resources: Map; +} + +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; +} + +export interface IMapGraph { + /** 不可到达的区域 */ + readonly unreachableArea: Set; + /** 当前拓扑图包含的区域信息 */ + readonly areas: Set; + /** 当前拓扑图的所有入口 */ + readonly entries: Set; + /** 坐标至其所在节点的映射,可以根据坐标获取其对应的节点 */ + readonly nodeMap: Map; +} + +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; +}