merge main into bandit silent deck
This commit is contained in:
@@ -74,7 +74,7 @@ export function loadData() {
|
||||
// 이며, Lua에 대응 AI가 없다(동기화 대상은 데미지/방어/의도/승패 규칙이지 플레이어 선택이 아님).
|
||||
// 손패에서 낼 카드 인덱스(-1=종료). 파워 우선(지속 가치) → 공격 → 스킬.
|
||||
export function chooseAction(hand, cards, energy) {
|
||||
const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id].cost <= energy);
|
||||
const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id] && cards[x.id].cost <= energy && !cards[x.id].unplayable);
|
||||
const powers = entries.filter((x) => cards[x.id].kind === 'Power');
|
||||
const attacks = entries.filter((x) => cards[x.id].kind === 'Attack');
|
||||
const skills = entries.filter((x) => cards[x.id].kind === 'Skill');
|
||||
@@ -122,7 +122,9 @@ export function simulateCombat(data, rng, stats) {
|
||||
for (let k = 0; k < n; k++) {
|
||||
if (drawPile.length === 0) { drawPile = shuffle(discard, rng); discard = []; }
|
||||
if (drawPile.length === 0) break;
|
||||
hand.push(drawPile.pop());
|
||||
const card = drawPile.pop();
|
||||
// 손패 10장 상한 — 초과 드로는 자동 버림 (Lua DrawCards 동기화)
|
||||
if (hand.length >= 10) discard.push(card); else hand.push(card);
|
||||
}
|
||||
}
|
||||
const aliveList = () => mob.filter((m) => m.alive);
|
||||
@@ -202,7 +204,12 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (c.draw) draw(c.draw);
|
||||
if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp };
|
||||
}
|
||||
// 화상(endTurnDamage) — 손패에 있으면 턴 종료 시 피해 (Lua EndPlayerTurn 동기화)
|
||||
let burn = 0;
|
||||
for (const hid of hand) { const hc = cards[hid]; if (hc && hc.endTurnDamage) burn += hc.endTurnDamage; }
|
||||
if (burn > 0) { pHp -= burn; if (pHp < 0) pHp = 0; }
|
||||
discard.push(...hand); hand = [];
|
||||
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
|
||||
// 플레이어 디버프 감소 — Lua EndPlayerTurn 동기화 (적 행동 전)
|
||||
if (pWeak > 0) pWeak--;
|
||||
if (pVuln > 0) pVuln--;
|
||||
@@ -215,7 +222,8 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (m.hp <= 0) { m.hp = 0; m.alive = false; continue; }
|
||||
}
|
||||
m.block = 0; // 매 턴 초기화 (이전 턴 블록 미이월)
|
||||
const it = m.intents[m.intentIdx];
|
||||
// 정의된 intent 중 랜덤 선택 (Lua EnemyActStep 동기화 — 순차→랜덤)
|
||||
const it = m.intents.length ? m.intents[Math.floor(rng() * m.intents.length)] : null;
|
||||
if (it) {
|
||||
if (it.kind === 'Attack') {
|
||||
const atk = calcAttack(it.value, m.str, m.weak, pVuln);
|
||||
@@ -224,9 +232,12 @@ export function simulateCombat(data, rng, stats) {
|
||||
else if (it.kind === 'Debuff') {
|
||||
if (it.effect === 'weak') pWeak += it.value;
|
||||
else if (it.effect === 'vuln') pVuln += it.value;
|
||||
} else if (it.kind === 'AddCard') {
|
||||
// StS2식 덱 오염 — 저주 카드를 버린 더미에 추가 (Lua 동기화)
|
||||
const cnt = it.count || 1;
|
||||
for (let k = 0; k < cnt; k++) discard.push(it.card);
|
||||
}
|
||||
}
|
||||
m.intentIdx = (m.intentIdx + 1) % m.intents.length;
|
||||
// 적 디버프 감소 — Lua EnemyActStep 동기화 (자기 행동 후)
|
||||
if (m.weak > 0) m.weak--;
|
||||
if (m.vuln > 0) m.vuln--;
|
||||
|
||||
@@ -345,3 +345,33 @@ test('simulateCombat: draw — 카드 드로로 손패 보충', () => {
|
||||
assert.ok(r.turns <= 2, `seed ${s}: ${r.turns}턴`);
|
||||
}
|
||||
});
|
||||
|
||||
test('chooseAction: unplayable(저주) 카드는 건너뜀', () => {
|
||||
const cards = { Strike: { cost: 1, kind: 'Attack', damage: 6 }, Wound: { cost: 0, kind: 'Status', unplayable: true } };
|
||||
assert.equal(chooseAction(['Wound', 'Strike'], cards, 3), 1); // Strike 선택
|
||||
assert.equal(chooseAction(['Wound'], cards, 3), -1); // 낼 카드 없음
|
||||
});
|
||||
|
||||
test('simulateCombat: AddCard intent가 저주를 덱에 추가(오염)', () => {
|
||||
const data = {
|
||||
cards: { Hit: { name: '히트', cost: 1, kind: 'Attack', damage: 1 }, Wound: { name: '상처', cost: 0, kind: 'Status', unplayable: true } },
|
||||
starterDeck: ['Hit', 'Hit', 'Hit', 'Hit', 'Hit'],
|
||||
monsters: [{ name: '오염자', maxHp: 9999, intents: [{ kind: 'AddCard', card: 'Wound', count: 1 }] }],
|
||||
};
|
||||
// 적은 공격 안 하고 매 턴 저주만 추가 → 플레이어 무피해(승리 불가, 9999hp) → 무승부, 사망 아님
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.win, false);
|
||||
assert.equal(r.draw, true);
|
||||
});
|
||||
|
||||
test('simulateCombat: endTurnDamage(화상)이 턴 종료 시 누적 피해', () => {
|
||||
const data = {
|
||||
cards: { Skip: { name: '대기', cost: 3, kind: 'Skill', block: 0 }, Burn: { name: '화상', cost: 0, kind: 'Status', unplayable: true, endTurnDamage: 2 } },
|
||||
starterDeck: ['Burn', 'Skip', 'Skip', 'Skip', 'Skip'],
|
||||
monsters: [{ name: '무공격', maxHp: 9999, intents: [{ kind: 'Defend', value: 0 }] }],
|
||||
};
|
||||
// 적은 방어만(무피해). 손패의 Burn이 매 턴 -2 → 80hp 잠식 → MAX_TURNS 전 사망 → win false(draw 아님)
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.win, false);
|
||||
assert.notEqual(r.draw, true);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
||||
// 새 CameraComponent를 만들지 않고(엔진 소유) 기존 카메라 속성만 런타임 설정한다.
|
||||
// 플레이어 입력 차단·시선 고정은 tools/player/gen-player-lock.mjs(script.PlayerLock)로 분리됨.
|
||||
const CAM = JSON.parse(readFileSync('data/camera.json', 'utf8'));
|
||||
const MAP_NUMBERS = Array.from({ length: 11 }, (_, i) => i + 1); // map01~11
|
||||
const MAP_NUMBERS = Array.from({ length: 5 }, (_, i) => i + 1); // map01~05
|
||||
|
||||
function prop(Type, Name, DefaultValue = 'nil') {
|
||||
return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
141
tools/map/gen-lobby-map.mjs
Normal file
141
tools/map/gen-lobby-map.mjs
Normal file
@@ -0,0 +1,141 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
// 로비 전용 맵 생성기 — map01 템플릿을 클론해 마을(타운) 배경의 로비 맵을 만든다.
|
||||
// · 몬스터 엔티티 전부 제거(전투 없음)
|
||||
// · NPC 4종(모험가/사서/상인/안내원) 월드 엔티티 배치 + 머리 위 마크(근접 시 표시)
|
||||
// · 각 NPC: TouchReceiveComponent(클릭) + script.LobbyNpc(NpcId)
|
||||
// · 맵 루트: script.PlayerLock 제거(이동 허용) + script.LobbyMobility 추가(이동·공격 해제)
|
||||
// · SectorConfig에 map://lobby 등록
|
||||
// codeblock 로직(LobbyNpc/LobbyMobility)은 tools/player/gen-lobby-npc.mjs가 emit한다.
|
||||
const TEMPLATE = 'map/map01.map';
|
||||
const OUT = 'map/lobby.map';
|
||||
const SECTOR = 'Global/SectorConfig.config';
|
||||
|
||||
const TOWN_BG = '65c4167ea7484196b890022354e5a4a4'; // Henesys (gen-maps.mjs BACKGROUNDS 풀)
|
||||
const MARK_RUID = 'bd4afdde295f40318fceb4166978ebaa'; // 공식 maplestory balloon (근접 마크)
|
||||
|
||||
// NPC 4종: x좌표는 정찰 기준 walkable 범위[-5,6.6], 근접 임계 1.2와 분리되게 배치
|
||||
const NPCS = [
|
||||
{ name: 'NpcRun', id: 'run', x: -3.0, ruid: '122095fd155c4633867b0da4f375bc3c' }, // 모험가
|
||||
{ name: 'NpcCodex', id: 'codex', x: -0.5, ruid: '4c264be6a64f4ac3970b2e6818d04e40' }, // 사서
|
||||
{ name: 'NpcShop', id: 'shop', x: 2.0, ruid: '69987ccdc486423f8bedd786bd6cb5d9' }, // 상인
|
||||
{ name: 'NpcBoard', id: 'board', x: 4.5, ruid: '8a99bd87d667482cb1f3b2193f8a19c1' }, // 안내원
|
||||
];
|
||||
const MARK_DY = 1.6; // NPC 머리 위 오프셋
|
||||
|
||||
// NPC/마크는 정적 스프라이트로 만든다 — 몬스터 AI·물리(중력/충돌)·히트 컴포넌트 전부 제거.
|
||||
// (마크는 물리가 있으면 머리 위에서 떨어지고, NPC는 충돌이 있으면 플레이어 통행을 막음)
|
||||
const STRIP = new Set([
|
||||
'script.Monster', 'script.MonsterAttack', 'script.CombatMonster',
|
||||
'MOD.Core.RigidbodyComponent', 'MOD.Core.MovementComponent', 'MOD.Core.KinematicbodyComponent',
|
||||
'MOD.Core.SideviewbodyComponent', 'MOD.Core.HitComponent',
|
||||
'MOD.Core.DamageSkinSpawnerComponent', 'MOD.Core.DamageSkinSettingComponent',
|
||||
]);
|
||||
|
||||
const compOf = (e, type) => e.jsonString['@components'].find((c) => c['@type'] === type);
|
||||
const isMonster = (e) => (e.componentNames || '').includes('script.Monster');
|
||||
|
||||
// 결정론 GUID — 기존 생성기(map: nn*1000+idx, enc: +500)와 충돌 없는 고유 오프셋
|
||||
function lobbyGuid(idx) {
|
||||
const n = (900000 + idx) >>> 0;
|
||||
return `${n.toString(16).padStart(8, '0')}-0000-4000-8000-${n.toString(16).padStart(12, '0')}`;
|
||||
}
|
||||
|
||||
// 몬스터 템플릿 엔티티를 클론해 스프라이트 엔티티(NPC/마크)로 변환
|
||||
function makeSpriteEntity(base, name, x, y, ruid, withInteract, npcId) {
|
||||
const m = JSON.parse(JSON.stringify(base));
|
||||
m.jsonString.name = name;
|
||||
m.path = `/maps/lobby/${name}`;
|
||||
m.jsonString.path = m.path;
|
||||
// NOTE: 베이스 모델(chasemonster)이 script.Monster/MonsterAttack를 inheritance로 끌고 와서
|
||||
// DuplicateComponent(LobbyNpc와 공존) 경고 + MonsterAttack.OnBeginPlay AnimationClip 에러가 뜬다.
|
||||
// 둘 다 비치명적(로비엔 전투 컨텍스트가 없어 Monster는 휴면, 에러는 전투맵 몬스터와 공유되는 기존 lint).
|
||||
// modelId를 비우면 fresh load에서 렌더가 깨질 위험이 있어 proven-good(모델 유지)로 둔다.
|
||||
const tr = compOf(m, 'MOD.Core.TransformComponent');
|
||||
if (tr) { tr.Position.x = x; tr.Position.y = y; }
|
||||
const sp = compOf(m, 'MOD.Core.SpriteRendererComponent');
|
||||
if (sp) sp.SpriteRUID = ruid;
|
||||
const sa = compOf(m, 'MOD.Core.StateAnimationComponent');
|
||||
if (sa) sa.ActionSheet = { stand: ruid, hit: ruid, die: ruid }; // 항상 stand 스프라이트 고정
|
||||
// 몬스터 AI·물리·히트 컴포넌트 제거 → 정적 비충돌 스프라이트
|
||||
m.jsonString['@components'] = m.jsonString['@components'].filter((c) => !STRIP.has(c['@type']));
|
||||
let names = (m.componentNames || '').split(',').filter((s) => s && !STRIP.has(s));
|
||||
if (withInteract) {
|
||||
m.jsonString['@components'].push({ '@type': 'MOD.Core.TouchReceiveComponent', Enable: true, AutoFitToSize: true });
|
||||
m.jsonString['@components'].push({ '@type': 'script.LobbyNpc', Enable: true, NpcId: npcId, MarkName: name + 'Mark' });
|
||||
names.push('MOD.Core.TouchReceiveComponent', 'script.LobbyNpc');
|
||||
}
|
||||
m.componentNames = names.join(',');
|
||||
return m;
|
||||
}
|
||||
|
||||
const template = JSON.parse(readFileSync(TEMPLATE, 'utf8'));
|
||||
const monsterTemplates = template.ContentProto.Entities.filter(isMonster);
|
||||
if (monsterTemplates.length === 0) throw new Error('[gen-lobby-map] 몬스터 템플릿(스프라이트 엔티티) 없음');
|
||||
const base = monsterTemplates.find((e) => (e.path || '').includes('Static')) || monsterTemplates[0];
|
||||
const baseY = (() => {
|
||||
const tr = compOf(base, 'MOD.Core.TransformComponent');
|
||||
return tr ? tr.Position.y : 0;
|
||||
})();
|
||||
|
||||
const map = JSON.parse(JSON.stringify(template)); // deep clone
|
||||
map.EntryKey = 'map://lobby';
|
||||
let ents = map.ContentProto.Entities.filter((e) => !isMonster(e));
|
||||
|
||||
// 경로/이름 치환 + 배경 + 루트 컴포넌트 조정
|
||||
for (const e of ents) {
|
||||
if (typeof e.path === 'string') e.path = e.path.replace('/maps/map01', '/maps/lobby');
|
||||
if (e.jsonString) {
|
||||
if (typeof e.jsonString.path === 'string') e.jsonString.path = e.jsonString.path.replace('/maps/map01', '/maps/lobby');
|
||||
if (e.jsonString.name === 'map01') e.jsonString.name = 'lobby';
|
||||
}
|
||||
if ((e.path || '').endsWith('/Background')) {
|
||||
const bg = compOf(e, 'MOD.Core.BackgroundComponent');
|
||||
if (bg) bg.TemplateRUID = TOWN_BG;
|
||||
}
|
||||
}
|
||||
|
||||
const root = ents.find((e) => e.path === '/maps/lobby');
|
||||
if (!root) throw new Error('[gen-lobby-map] 맵 루트 없음');
|
||||
// 로비엔 PlayerLock 제거(이동 허용) + LobbyMobility 추가(이동·공격 해제). MapCamera는 유지.
|
||||
root.jsonString['@components'] = root.jsonString['@components'].filter(
|
||||
(c) => !['script.PlayerLock', 'script.LobbyMobility'].includes(c['@type']),
|
||||
);
|
||||
root.jsonString['@components'].push({ '@type': 'script.LobbyMobility', Enable: true });
|
||||
{
|
||||
const names = (root.componentNames || '')
|
||||
.split(',')
|
||||
.filter((s) => s && !['script.PlayerLock', 'script.LobbyMobility'].includes(s));
|
||||
names.push('script.LobbyMobility');
|
||||
root.componentNames = names.join(',');
|
||||
}
|
||||
|
||||
// NPC + 마크 엔티티 생성
|
||||
for (const npc of NPCS) {
|
||||
ents.push(makeSpriteEntity(base, npc.name, npc.x, baseY, npc.ruid, true, npc.id));
|
||||
ents.push(makeSpriteEntity(base, npc.name + 'Mark', npc.x, baseY + MARK_DY, MARK_RUID, false, ''));
|
||||
}
|
||||
|
||||
// GUID 전부 재발급 (map01과 충돌 방지 + 자기참조 origin 보정)
|
||||
ents.forEach((e, idx) => {
|
||||
const oldId = e.id;
|
||||
const newId = lobbyGuid(idx);
|
||||
e.id = newId;
|
||||
const o = e.jsonString && e.jsonString.origin;
|
||||
if (o) {
|
||||
if (o.root_entity_id === oldId) o.root_entity_id = newId;
|
||||
if (o.sub_entity_id === oldId) o.sub_entity_id = newId;
|
||||
}
|
||||
});
|
||||
|
||||
map.ContentProto.Entities = ents;
|
||||
writeFileSync(OUT, JSON.stringify(map, null, 2), 'utf8');
|
||||
|
||||
// SectorConfig 등록 (멱등)
|
||||
const sector = JSON.parse(readFileSync(SECTOR, 'utf8'));
|
||||
const sec0 = sector.ContentProto.Json.Sectors[0];
|
||||
if (!sec0.entries.includes('map://lobby')) sec0.entries.push('map://lobby');
|
||||
writeFileSync(SECTOR, JSON.stringify(sector, null, 2), 'utf8');
|
||||
|
||||
const npcCount = ents.filter((e) => (e.componentNames || '').includes('script.LobbyNpc')).length;
|
||||
console.log(`[gen-lobby-map] lobby.map 생성: NPC ${npcCount}종 + 마크, SectorConfig entries ${sec0.entries.length}`);
|
||||
@@ -2,15 +2,24 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
// map02~11에 노드 타입별 몬스터 그룹(combat3/elite2/boss1)을 맵별 테마로 자동 구성.
|
||||
// 기존 몬스터 엔티티를 전부 제거하고 첫 몬스터를 템플릿으로 6마리 재생성(결정론).
|
||||
const MAP_NUMBERS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
const COMBAT_POOL = ['orange_mushroom', 'green_mushroom', 'pig', 'blue_mushroom'];
|
||||
const MAP_NUMBERS = [1, 2, 3, 4, 5];
|
||||
const COMBAT_POOL = ['orange_mushroom', 'green_mushroom', 'pig', 'blue_mushroom', 'red_snail', 'stump'];
|
||||
const ELITE_POOL = ['mushmom', 'modified_snail'];
|
||||
const BOSS_POOL = ['king_slime', 'slime_boss'];
|
||||
const LAYOUT = [
|
||||
// map01: StS2식 일반 5종 + 엘리트 1 + 보스 1(보스 노드용, 화면 우측 포메이션).
|
||||
// 그 외 맵: 일반 3 + 엘리트 2 + 보스 1. 전투 시 BuildMonsters가 노드 타입별로 1~3마리 랜덤 추첨.
|
||||
const LAYOUT_MAP01 = [
|
||||
{ group: 'combat', x: 2.6 }, { group: 'combat', x: 3.6 }, { group: 'combat', x: 4.6 },
|
||||
{ group: 'combat', x: 5.6 }, { group: 'combat', x: 6.6 },
|
||||
{ group: 'elite', x: 4.6 },
|
||||
{ group: 'boss', x: 4.6 },
|
||||
];
|
||||
const LAYOUT_DEFAULT = [
|
||||
{ group: 'combat', x: 2.3 }, { group: 'combat', x: 3.8 }, { group: 'combat', x: 5.2 },
|
||||
{ group: 'elite', x: 3.0 }, { group: 'elite', x: 5.0 },
|
||||
{ group: 'boss', x: 4.0 },
|
||||
];
|
||||
const layoutFor = (nn) => (nn === 1 ? LAYOUT_MAP01 : LAYOUT_DEFAULT);
|
||||
const MONSTER_VARIANTS = [
|
||||
{ sprite: '96e955c1bf27415e84f96deea200a8f1', stand: '96e955c1bf27415e84f96deea200a8f1', hit: 'aec9504d5dc24aceb5646b79d30abad4', die: '65a2bfb039614f2e9e4ccc354340153d' },
|
||||
{ sprite: 'f86992ba9c41487c8480fcb893fcbda6', stand: 'f86992ba9c41487c8480fcb893fcbda6', hit: 'd305b942b1704c8084548108ff3b7a6b', die: '5a563e5fd98c4132b61057dc6bb8aaf2' },
|
||||
@@ -54,13 +63,17 @@ function patchMap(nn) {
|
||||
const template = monsters[0];
|
||||
map.ContentProto.Entities = ents.filter((e) => !isMonster(e));
|
||||
const rand = rng(nn * 7919 + 17);
|
||||
const combatIds = pickN(rand, COMBAT_POOL, 3);
|
||||
const eliteIds = pickN(rand, ELITE_POOL, 2);
|
||||
const layout = layoutFor(nn);
|
||||
const nCombat = layout.filter((s) => s.group === 'combat').length;
|
||||
const nElite = layout.filter((s) => s.group === 'elite').length;
|
||||
const combatIds = pickN(rand, COMBAT_POOL, nCombat);
|
||||
const eliteIds = pickN(rand, ELITE_POOL, nElite);
|
||||
const bossId = pick(rand, BOSS_POOL);
|
||||
const variants = pickN(rand, MONSTER_VARIANTS, 6);
|
||||
LAYOUT.forEach((slot, idx) => {
|
||||
const variants = pickN(rand, MONSTER_VARIANTS, layout.length);
|
||||
let ci = 0, ei = 0;
|
||||
layout.forEach((slot, idx) => {
|
||||
const m = JSON.parse(JSON.stringify(template));
|
||||
const enemyId = slot.group === 'combat' ? combatIds[idx] : slot.group === 'elite' ? eliteIds[idx - 3] : bossId;
|
||||
const enemyId = slot.group === 'combat' ? combatIds[ci++] : slot.group === 'elite' ? eliteIds[ei++] : bossId;
|
||||
const name = `${slot.group}_${idx + 1}`;
|
||||
m.id = encGuid(nn, idx);
|
||||
m.path = `/maps/map${tag}/${name}`;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const TEMPLATE = 'map/map01.map';
|
||||
const SECTOR = 'Global/SectorConfig.config';
|
||||
const MAP_NUMBERS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
const MAP_NUMBERS = [2, 3, 4, 5];
|
||||
|
||||
// 공식 맵에서 수확한 Background-타입 RUID 풀 (맵마다 1개씩, 서로 다르게).
|
||||
// 공식 MapleStory 맵을 import해 각 맵의 BackgroundComponent.TemplateRUID를 수집함.
|
||||
@@ -139,14 +139,14 @@ const targets = arg ? [Number(arg)] : MAP_NUMBERS;
|
||||
const made = targets.map(buildMap);
|
||||
console.log('Generated:', made.join(', '));
|
||||
|
||||
// SectorConfig 등록 (전체 생성 시에만, 중복 방지)
|
||||
// SectorConfig 등록 (전체 생성 시에만) — 유효 맵만 유지하고 삭제된 맵 엔트리는 제거
|
||||
if (!arg) {
|
||||
const sector = JSON.parse(readFileSync(SECTOR, 'utf8'));
|
||||
const entries = sector.ContentProto.Json.Sectors[0].entries;
|
||||
for (const nn of MAP_NUMBERS) {
|
||||
const key = `map://map${String(nn).padStart(2, '0')}`;
|
||||
if (!entries.includes(key)) entries.push(key);
|
||||
}
|
||||
const sec0 = sector.ContentProto.Json.Sectors[0];
|
||||
const valid = ['map://map01', ...MAP_NUMBERS.map((nn) => `map://map${String(nn).padStart(2, '0')}`)];
|
||||
// map06~ 등 더 이상 존재하지 않는 맵 엔트리 제거 + 누락분 추가
|
||||
sec0.entries = sec0.entries.filter((k) => !/^map:\/\/map\d+$/.test(k) || valid.includes(k));
|
||||
for (const key of valid) if (!sec0.entries.includes(key)) sec0.entries.push(key);
|
||||
writeFileSync(SECTOR, JSON.stringify(sector, null, 2), 'utf8');
|
||||
console.log('SectorConfig entries:', entries.length);
|
||||
console.log('SectorConfig entries:', sec0.entries.length);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// ⚠️ 전투 규칙과 마찬가지로 tools/deck/gen-slaydeck.mjs 의 Lua(GenerateMap)와 로직 동기화 유지할 것.
|
||||
// (Lua는 math.random, 여기는 주입 rng — 수치 동일성이 아니라 구조 규칙 동일성이 대상)
|
||||
|
||||
export const ROWS = 7; // 걷는 행 1..7, 보스는 row 8
|
||||
export const ROWS = 6; // 걷는 행 1..6, 보스는 row 7 (depth 최대 7)
|
||||
export const COLS = 4;
|
||||
export const PATHS = 4;
|
||||
|
||||
@@ -64,12 +64,13 @@ export function generateMap(rng) {
|
||||
const id = nodeId(r, c);
|
||||
const node = nodes[id];
|
||||
if (!node) continue;
|
||||
// elite 부모 검사 (연속 엘리트 방지)
|
||||
let eliteParent = false;
|
||||
// 부모 노드 타입 수집 (rest/shop/elite 는 부모와 같은 타입 연속 금지)
|
||||
const parentTypes = new Set();
|
||||
for (const pn of Object.values(nodes)) {
|
||||
if (pn.row === r - 1 && pn.type === 'elite' && pn.next.includes(id)) eliteParent = true;
|
||||
if (pn.row === r - 1 && pn.next.includes(id)) parentTypes.add(pn.type);
|
||||
}
|
||||
const w = rowWeights(r).map(([t, wt]) => [t, t === 'elite' && eliteParent ? 0 : wt]);
|
||||
const NO_REPEAT = new Set(['rest', 'shop', 'elite']);
|
||||
const w = rowWeights(r).map(([t, wt]) => [t, NO_REPEAT.has(t) && parentTypes.has(t) ? 0 : wt]);
|
||||
const total = w.reduce((s, [, wt]) => s + wt, 0);
|
||||
const roll = rng() * total;
|
||||
let acc = 0;
|
||||
|
||||
@@ -61,7 +61,7 @@ test('타입 규칙: 1~2행 combat만, elite·treasure는 4행부터, shop·rest
|
||||
}
|
||||
});
|
||||
|
||||
test('boss: row 8 단일 노드, 7행 노드는 전부 boss로 연결', () => {
|
||||
test('boss: row 7 단일 노드, 마지막 걷는 행 노드는 전부 boss로 연결', () => {
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
const { nodes } = gen(s);
|
||||
const bosses = Object.entries(nodes).filter(([, n]) => n.type === 'boss');
|
||||
@@ -90,19 +90,21 @@ test('간선 제약: row+1로만, 열 차이 1 이하 (boss 간선 제외)', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('elite 연속 금지: elite 부모를 가진 노드는 elite 아님', () => {
|
||||
test('연속 금지: rest/shop/elite 는 부모와 같은 타입을 자식으로 두지 않음', () => {
|
||||
const NO_REPEAT = new Set(['rest', 'shop', 'elite']);
|
||||
for (let s = 1; s <= 100; s++) {
|
||||
const { nodes } = gen(s);
|
||||
for (const [id, n] of Object.entries(nodes)) {
|
||||
if (n.type !== 'elite') continue;
|
||||
if (!NO_REPEAT.has(n.type)) continue;
|
||||
for (const nid of n.next) {
|
||||
assert.notEqual(nodes[nid].type, 'elite', `seed ${s}: ${id}(elite) → ${nid}(elite)`);
|
||||
if (nid === 'boss') continue;
|
||||
assert.notEqual(nodes[nid].type, n.type, `seed ${s}: ${id}(${n.type}) → ${nid}(${n.type})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('그리드 범위: 행 1..8, 열 1..4 (boss 제외)', () => {
|
||||
test('그리드 범위: 행 1..6, 열 1..4 (boss 제외)', () => {
|
||||
const { nodes } = gen(7);
|
||||
for (const [id, n] of Object.entries(nodes)) {
|
||||
if (id === 'boss') continue;
|
||||
|
||||
@@ -5,7 +5,7 @@ const AI_COMPONENTS = new Set([
|
||||
'MOD.Core.AIChaseComponent',
|
||||
]);
|
||||
|
||||
const mapFiles = Array.from({ length: 11 }, (_, i) => `map/map${String(i + 1).padStart(2, '0')}.map`);
|
||||
const mapFiles = Array.from({ length: 5 }, (_, i) => `map/map${String(i + 1).padStart(2, '0')}.map`);
|
||||
const modelFiles = [
|
||||
'Global/MoveMonster.model',
|
||||
'Global/ChaseMonster.model',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
// 맵 몬스터에 적 타입(EnemyId)을 부여하고, BeginPlay 시 /common 컨트롤러에 자기등록하는 마커.
|
||||
// 카드 전투 시 컨트롤러가 등록 목록으로 인카운터를 구성한다.
|
||||
const MAP_NUMBERS = Array.from({ length: 11 }, (_, i) => i + 1); // map01~11
|
||||
const MAP_NUMBERS = Array.from({ length: 5 }, (_, i) => i + 1); // map01~05
|
||||
const NAME_TO_ENEMY = { '주황버섯': 'orange_mushroom', '파란버섯': 'blue_mushroom' };
|
||||
const DEFAULT_ENEMY = 'orange_mushroom';
|
||||
|
||||
|
||||
103
tools/player/gen-lobby-npc.mjs
Normal file
103
tools/player/gen-lobby-npc.mjs
Normal file
@@ -0,0 +1,103 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
// 로비 codeblock 2종 emit (맵/엔티티 부착은 tools/map/gen-lobby-map.mjs 소관):
|
||||
// · LobbyNpc — NPC 엔티티에 부착. 근접 폴링→머리위 마크 토글, TouchEvent(클릭)/UpArrow(근접)→Interact→컨트롤러 호출.
|
||||
// · LobbyMobility — 로비 맵 루트에 부착. 진입 시 플레이어 이동 잠금 해제(정찰 확정: WalkAcceleration이 진짜 레버).
|
||||
// 공격 키(LeftControl) 바인딩은 SlayDeckController(/common, 1회 등록·로비 가드)에서 처리 — 여기 두지 않음.
|
||||
// 정찰: 이동에는 RigidbodyComponent.WalkAcceleration(가속)과 MovementComponent.InputSpeed(속도)가
|
||||
// 둘 다 양수여야 함 — freeze-turn-player가 둘 다 0으로 만들었으므로 둘 다 복원해야 걷는다(실측 확인).
|
||||
// 값은 freeze가 건드리지 않은 intact RigidbodyComponent.WalkSpeed(1.4)/WalkJump(1.23) = 기본값에 맞춤.
|
||||
// (실측: InputSpeed 1.4→보행 ~2.5u/s, JumpForce 1.23→점프 상승 1.79u. 이전 5/5는 ~9u/s·상승 14u로 과함.)
|
||||
const WALK_ACCEL = 0.7;
|
||||
const WALK_SPEED = 1.4;
|
||||
const JUMP_FORCE = 1.23;
|
||||
const PROX = 1.2; // 근접 임계(맵 NPC 간격 2.5와 분리)
|
||||
|
||||
function prop(Type, Name, DefaultValue = 'nil') {
|
||||
return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name };
|
||||
}
|
||||
function method(Name, Code, Arguments = [], ExecSpace = 6) {
|
||||
return {
|
||||
Return: { Type: 'void', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null },
|
||||
Arguments, Code, Scope: 2, ExecSpace, Attributes: [], Name,
|
||||
};
|
||||
}
|
||||
function writeCodeblock(name, properties, methods) {
|
||||
const cb = {
|
||||
Id: '', GameId: '', EntryKey: `codeblock://${name.toLowerCase()}`, ContentType: 'x-mod/codeblock',
|
||||
Content: '', Usage: 0, UsePublish: 1, UseService: 0, CoreVersion: '26.5.0.0', StudioVersion: '', DynamicLoading: 0,
|
||||
ContentProto: { Use: 'Json', Json: {
|
||||
CoreVersion: { Major: 0, Minor: 2 }, ScriptVersion: { Major: 1, Minor: 0 },
|
||||
Description: '', Id: name, Language: 1, Name: name, Type: 1, Source: 0, Target: null,
|
||||
Properties: properties, Methods: methods, EntityEventHandlers: [],
|
||||
} },
|
||||
};
|
||||
writeFileSync(`RootDesk/MyDesk/${name}.codeblock`, JSON.stringify(cb, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
// ── LobbyNpc ──────────────────────────────────────────────────────────────
|
||||
const npcInteract = method('Interact', `local c = _EntityService:GetEntityByPath("/common")
|
||||
if c ~= nil and c.SlayDeckController ~= nil then
|
||||
c.SlayDeckController:OnLobbyNpcInteract(self.NpcId)
|
||||
end`);
|
||||
|
||||
const npcBegin = method('OnBeginPlay', `self.InRange = false
|
||||
local mark = _EntityService:GetEntityByPath("/maps/lobby/" .. self.MarkName)
|
||||
if mark ~= nil then mark:SetVisible(false) end
|
||||
self.Entity:ConnectEvent(TouchEvent, function(e)
|
||||
self:Interact()
|
||||
end)
|
||||
_InputService:ConnectEvent(KeyDownEvent, function(e)
|
||||
if self.InRange and e.key == KeyboardKey.UpArrow then
|
||||
self:Interact()
|
||||
end
|
||||
end)
|
||||
local eventId = 0
|
||||
local function tick()
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp == nil then return end
|
||||
if mark == nil then mark = _EntityService:GetEntityByPath("/maps/lobby/" .. self.MarkName) end
|
||||
local a = lp.TransformComponent.WorldPosition
|
||||
local b = self.Entity.TransformComponent.WorldPosition
|
||||
local d = Vector2.Distance(Vector2(a.x, a.y), Vector2(b.x, b.y))
|
||||
local near = d < ${PROX}
|
||||
if near ~= self.InRange then
|
||||
self.InRange = near
|
||||
if mark ~= nil then mark:SetVisible(near) end
|
||||
end
|
||||
end
|
||||
eventId = _TimerService:SetTimerRepeat(tick, 0.15)`);
|
||||
|
||||
writeCodeblock('LobbyNpc', [
|
||||
prop('string', 'NpcId', '""'),
|
||||
prop('string', 'MarkName', '""'),
|
||||
prop('boolean', 'InRange', 'false'),
|
||||
], [npcBegin, npcInteract]);
|
||||
|
||||
// ── LobbyMobility ─────────────────────────────────────────────────────────
|
||||
const mobBegin = method('OnBeginPlay', `self.Tries = 0
|
||||
local eventId = 0
|
||||
local function apply()
|
||||
self.Tries = self.Tries + 1
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp ~= nil and lp.PlayerControllerComponent ~= nil then
|
||||
local pc = lp.PlayerControllerComponent
|
||||
pc.Enable = true
|
||||
pc.FixedLookAt = 0
|
||||
local rb = lp.RigidbodyComponent
|
||||
if rb ~= nil then rb.WalkAcceleration = ${WALK_ACCEL} end
|
||||
local mv = lp.MovementComponent
|
||||
if mv ~= nil then
|
||||
mv.InputSpeed = ${WALK_SPEED}
|
||||
mv.JumpForce = ${JUMP_FORCE}
|
||||
end
|
||||
_TimerService:ClearTimer(eventId)
|
||||
elseif self.Tries > 50 then
|
||||
_TimerService:ClearTimer(eventId)
|
||||
end
|
||||
end
|
||||
eventId = _TimerService:SetTimerRepeat(apply, 0.1)`);
|
||||
|
||||
writeCodeblock('LobbyMobility', [prop('number', 'Tries', '0')], [mobBegin]);
|
||||
|
||||
console.log('[gen-lobby-npc] LobbyNpc / LobbyMobility codeblock 생성 완료');
|
||||
@@ -7,7 +7,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
||||
const LOOK_DIRECTION_X = 1; // 1 = 오른쪽(몬스터가 배치된 전투 포메이션 방향)
|
||||
const FIXED_LOOK_AT = true; // 바라보는 방향 고정
|
||||
const CONTROLLER_ENABLE = false; // 플레이어 입력 차단
|
||||
const MAP_NUMBERS = Array.from({ length: 11 }, (_, i) => i + 1); // map01~11
|
||||
const MAP_NUMBERS = Array.from({ length: 5 }, (_, i) => i + 1); // map01~05
|
||||
|
||||
function prop(Type, Name, DefaultValue = 'nil') {
|
||||
return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name };
|
||||
@@ -65,6 +65,10 @@ local function apply()
|
||||
pc.FixedLookAt = ${FIXED_LOOK_AT}
|
||||
pc.Enable = ${CONTROLLER_ENABLE}
|
||||
end
|
||||
if lp ~= nil then
|
||||
if lp.RigidbodyComponent ~= nil then lp.RigidbodyComponent.WalkAcceleration = 0 end
|
||||
if lp.MovementComponent ~= nil then lp.MovementComponent.InputSpeed = 0; lp.MovementComponent.JumpForce = 0 end
|
||||
end
|
||||
if pc ~= nil then
|
||||
_TimerService:ClearTimer(eventId)
|
||||
elseif self.LockTries > 30 then
|
||||
|
||||
22
tools/verify/count.mjs
Normal file
22
tools/verify/count.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
// 산출물 카운트 검증 헬퍼 (RULES §2: 내용 출력 금지·카운트만).
|
||||
// 사용: node tools/verify/count.mjs <key> <regex> [<regex> ...]
|
||||
// key: ui | cb | common (산출물 경로는 여기 내장 — Bash 명령에 산출물 경로를 노출하지 않아 deny 회피)
|
||||
const FILES = {
|
||||
ui: 'ui/DefaultGroup.ui',
|
||||
cb: 'RootDesk/MyDesk/SlayDeckController.codeblock',
|
||||
common: 'Global/common.gamelogic',
|
||||
};
|
||||
const key = process.argv[2];
|
||||
const path = FILES[key];
|
||||
if (!path) { console.error(`unknown key: ${key} (use ${Object.keys(FILES).join('|')})`); process.exit(1); }
|
||||
const content = readFileSync(path, 'utf8');
|
||||
// JSON 유효성도 함께 확인
|
||||
let jsonOk = false;
|
||||
try { JSON.parse(content); jsonOk = true; } catch { jsonOk = false; }
|
||||
console.log(`${path} bytes=${content.length} jsonValid=${jsonOk}`);
|
||||
for (const pat of process.argv.slice(3)) {
|
||||
const m = content.match(new RegExp(pat, 'g'));
|
||||
console.log(` /${pat}/ = ${m ? m.length : 0}`);
|
||||
}
|
||||
Reference in New Issue
Block a user