feat(combat): 몬스터 랜덤 구성·랜덤 행동·덱 오염(AddCard)·map01 로스터 (P14-3)
- 구성 랜덤화: BuildMonsters를 그룹별 수집 후 노드 타입별 추첨 (일반 1~3 / 엘리트 1+일반0~2 / 보스 1, MAX_MONSTERS=4 내) - 행동 랜덤화: EnemyActStep 순차(round-robin) → 정의된 intent 중 math.random 선택 (스폰 시 시작 intent도 랜덤, sim-balance.mjs 미러 동기화) - StS2식 덱 오염: AddCard intent 신규 — 저주 카드(Wound/Burn)를 버린 더미에 추가 · Wound=사용불가 사석, Burn=사용불가+턴종료 피해2(EndPlayerTurn 처리) · PlayCard unplayable 가드, CardPool class 필터로 보상/상점 자동 제외 · luaIntentsArray(card/count)·luaCardsTable(unplayable/curse/endTurnDamage) 직렬화 - map01 인카운터: 일반 5종(주황/초록/빨강달팽이/파랑/돼지) + 엘리트1 + 보스1, 우측 포메이션 · enemies.json red_snail/stump 신규, blue_mushroom/mushmom에 AddCard intent · gen-map-encounters 레이아웃 맵별 분기 + 풀 인덱싱 일반화 - 막 배율 0.6→0.45(5막 기준 완화). sim 테스트 35/35 통과(신규 3 포함). 산출물 재생성 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -6,7 +6,8 @@
|
|||||||
},
|
},
|
||||||
"classToFrame": {
|
"classToFrame": {
|
||||||
"warrior": "warrior", "fighter": "warrior", "page": "warrior", "spearman": "warrior",
|
"warrior": "warrior", "fighter": "warrior", "page": "warrior", "spearman": "warrior",
|
||||||
"magician": "magician", "firepoison": "magician", "icelightning": "magician", "cleric": "magician"
|
"magician": "magician", "firepoison": "magician", "icelightning": "magician", "cleric": "magician",
|
||||||
|
"curse": "bandit"
|
||||||
},
|
},
|
||||||
"rewardWeights": { "normal": 70, "unique": 25, "legend": 5 }
|
"rewardWeights": { "normal": 70, "unique": 25, "legend": 5 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,6 +335,27 @@
|
|||||||
"desc": "피해 8",
|
"desc": "피해 8",
|
||||||
"image": "0265e103b4904f178b1c2bdcd54d5975",
|
"image": "0265e103b4904f178b1c2bdcd54d5975",
|
||||||
"rarity": "unique"
|
"rarity": "unique"
|
||||||
|
},
|
||||||
|
"Wound": {
|
||||||
|
"name": "상처",
|
||||||
|
"cost": 0,
|
||||||
|
"kind": "Status",
|
||||||
|
"desc": "사용할 수 없다. 손패를 막는 저주.",
|
||||||
|
"class": "curse",
|
||||||
|
"rarity": "normal",
|
||||||
|
"unplayable": true,
|
||||||
|
"curse": true
|
||||||
|
},
|
||||||
|
"Burn": {
|
||||||
|
"name": "화상",
|
||||||
|
"cost": 0,
|
||||||
|
"kind": "Status",
|
||||||
|
"desc": "사용 불가. 손패에 있으면 턴 종료 시 피해 2.",
|
||||||
|
"class": "curse",
|
||||||
|
"rarity": "normal",
|
||||||
|
"unplayable": true,
|
||||||
|
"curse": true,
|
||||||
|
"endTurnDamage": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"starterDecks": {
|
"starterDecks": {
|
||||||
|
|||||||
@@ -46,7 +46,8 @@
|
|||||||
"intents": [
|
"intents": [
|
||||||
{ "kind": "Attack", "value": 4 },
|
{ "kind": "Attack", "value": 4 },
|
||||||
{ "kind": "Attack", "value": 4 },
|
{ "kind": "Attack", "value": 4 },
|
||||||
{ "kind": "Attack", "value": 10 }
|
{ "kind": "Attack", "value": 10 },
|
||||||
|
{ "kind": "AddCard", "card": "Wound", "count": 1 }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"pig": {
|
"pig": {
|
||||||
@@ -67,6 +68,24 @@
|
|||||||
{ "kind": "Attack", "value": 9 }
|
{ "kind": "Attack", "value": 9 }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"red_snail": {
|
||||||
|
"name": "빨간 달팽이",
|
||||||
|
"maxHp": 14,
|
||||||
|
"intents": [
|
||||||
|
{ "kind": "Attack", "value": 5 },
|
||||||
|
{ "kind": "Defend", "value": 6 },
|
||||||
|
{ "kind": "Attack", "value": 7 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stump": {
|
||||||
|
"name": "나무토막",
|
||||||
|
"maxHp": 19,
|
||||||
|
"intents": [
|
||||||
|
{ "kind": "Defend", "value": 5 },
|
||||||
|
{ "kind": "Attack", "value": 8 },
|
||||||
|
{ "kind": "Attack", "value": 6 }
|
||||||
|
]
|
||||||
|
},
|
||||||
"mushmom": {
|
"mushmom": {
|
||||||
"name": "머쉬맘",
|
"name": "머쉬맘",
|
||||||
"maxHp": 75,
|
"maxHp": 75,
|
||||||
@@ -75,7 +94,8 @@
|
|||||||
{ "kind": "Debuff", "effect": "weak", "value": 2 },
|
{ "kind": "Debuff", "effect": "weak", "value": 2 },
|
||||||
{ "kind": "Attack", "value": 16 },
|
{ "kind": "Attack", "value": 16 },
|
||||||
{ "kind": "Attack", "value": 9 },
|
{ "kind": "Attack", "value": 9 },
|
||||||
{ "kind": "Defend", "value": 6 }
|
{ "kind": "Defend", "value": 6 },
|
||||||
|
{ "kind": "AddCard", "card": "Burn", "count": 1 }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"modified_snail": {
|
"modified_snail": {
|
||||||
|
|||||||
910
map/map01.map
910
map/map01.map
File diff suppressed because it is too large
Load Diff
@@ -6508,7 +6508,7 @@
|
|||||||
{
|
{
|
||||||
"@type": "script.CombatMonster",
|
"@type": "script.CombatMonster",
|
||||||
"Enable": true,
|
"Enable": true,
|
||||||
"EnemyId": "green_mushroom",
|
"EnemyId": "pig",
|
||||||
"Group": "combat"
|
"Group": "combat"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -6660,7 +6660,7 @@
|
|||||||
{
|
{
|
||||||
"@type": "script.CombatMonster",
|
"@type": "script.CombatMonster",
|
||||||
"Enable": true,
|
"Enable": true,
|
||||||
"EnemyId": "pig",
|
"EnemyId": "green_mushroom",
|
||||||
"Group": "combat"
|
"Group": "combat"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -6812,7 +6812,7 @@
|
|||||||
{
|
{
|
||||||
"@type": "script.CombatMonster",
|
"@type": "script.CombatMonster",
|
||||||
"Enable": true,
|
"Enable": true,
|
||||||
"EnemyId": "blue_mushroom",
|
"EnemyId": "stump",
|
||||||
"Group": "combat"
|
"Group": "combat"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6508,7 +6508,7 @@
|
|||||||
{
|
{
|
||||||
"@type": "script.CombatMonster",
|
"@type": "script.CombatMonster",
|
||||||
"Enable": true,
|
"Enable": true,
|
||||||
"EnemyId": "green_mushroom",
|
"EnemyId": "pig",
|
||||||
"Group": "combat"
|
"Group": "combat"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -6660,7 +6660,7 @@
|
|||||||
{
|
{
|
||||||
"@type": "script.CombatMonster",
|
"@type": "script.CombatMonster",
|
||||||
"Enable": true,
|
"Enable": true,
|
||||||
"EnemyId": "blue_mushroom",
|
"EnemyId": "red_snail",
|
||||||
"Group": "combat"
|
"Group": "combat"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6508,7 +6508,7 @@
|
|||||||
{
|
{
|
||||||
"@type": "script.CombatMonster",
|
"@type": "script.CombatMonster",
|
||||||
"Enable": true,
|
"Enable": true,
|
||||||
"EnemyId": "pig",
|
"EnemyId": "blue_mushroom",
|
||||||
"Group": "combat"
|
"Group": "combat"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -6660,7 +6660,7 @@
|
|||||||
{
|
{
|
||||||
"@type": "script.CombatMonster",
|
"@type": "script.CombatMonster",
|
||||||
"Enable": true,
|
"Enable": true,
|
||||||
"EnemyId": "blue_mushroom",
|
"EnemyId": "stump",
|
||||||
"Group": "combat"
|
"Group": "combat"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -6812,7 +6812,7 @@
|
|||||||
{
|
{
|
||||||
"@type": "script.CombatMonster",
|
"@type": "script.CombatMonster",
|
||||||
"Enable": true,
|
"Enable": true,
|
||||||
"EnemyId": "orange_mushroom",
|
"EnemyId": "green_mushroom",
|
||||||
"Group": "combat"
|
"Group": "combat"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6508,7 +6508,7 @@
|
|||||||
{
|
{
|
||||||
"@type": "script.CombatMonster",
|
"@type": "script.CombatMonster",
|
||||||
"Enable": true,
|
"Enable": true,
|
||||||
"EnemyId": "pig",
|
"EnemyId": "blue_mushroom",
|
||||||
"Group": "combat"
|
"Group": "combat"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -6660,7 +6660,7 @@
|
|||||||
{
|
{
|
||||||
"@type": "script.CombatMonster",
|
"@type": "script.CombatMonster",
|
||||||
"Enable": true,
|
"Enable": true,
|
||||||
"EnemyId": "orange_mushroom",
|
"EnemyId": "green_mushroom",
|
||||||
"Group": "combat"
|
"Group": "combat"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -6812,7 +6812,7 @@
|
|||||||
{
|
{
|
||||||
"@type": "script.CombatMonster",
|
"@type": "script.CombatMonster",
|
||||||
"Enable": true,
|
"Enable": true,
|
||||||
"EnemyId": "green_mushroom",
|
"EnemyId": "pig",
|
||||||
"Group": "combat"
|
"Group": "combat"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function loadData() {
|
|||||||
// 이며, Lua에 대응 AI가 없다(동기화 대상은 데미지/방어/의도/승패 규칙이지 플레이어 선택이 아님).
|
// 이며, Lua에 대응 AI가 없다(동기화 대상은 데미지/방어/의도/승패 규칙이지 플레이어 선택이 아님).
|
||||||
// 손패에서 낼 카드 인덱스(-1=종료). 파워 우선(지속 가치) → 공격 → 스킬.
|
// 손패에서 낼 카드 인덱스(-1=종료). 파워 우선(지속 가치) → 공격 → 스킬.
|
||||||
export function chooseAction(hand, cards, energy) {
|
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 powers = entries.filter((x) => cards[x.id].kind === 'Power');
|
||||||
const attacks = entries.filter((x) => cards[x.id].kind === 'Attack');
|
const attacks = entries.filter((x) => cards[x.id].kind === 'Attack');
|
||||||
const skills = entries.filter((x) => cards[x.id].kind === 'Skill');
|
const skills = entries.filter((x) => cards[x.id].kind === 'Skill');
|
||||||
@@ -202,7 +202,12 @@ export function simulateCombat(data, rng, stats) {
|
|||||||
if (c.draw) draw(c.draw);
|
if (c.draw) draw(c.draw);
|
||||||
if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp };
|
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 = [];
|
discard.push(...hand); hand = [];
|
||||||
|
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
|
||||||
// 플레이어 디버프 감소 — Lua EndPlayerTurn 동기화 (적 행동 전)
|
// 플레이어 디버프 감소 — Lua EndPlayerTurn 동기화 (적 행동 전)
|
||||||
if (pWeak > 0) pWeak--;
|
if (pWeak > 0) pWeak--;
|
||||||
if (pVuln > 0) pVuln--;
|
if (pVuln > 0) pVuln--;
|
||||||
@@ -215,7 +220,8 @@ export function simulateCombat(data, rng, stats) {
|
|||||||
if (m.hp <= 0) { m.hp = 0; m.alive = false; continue; }
|
if (m.hp <= 0) { m.hp = 0; m.alive = false; continue; }
|
||||||
}
|
}
|
||||||
m.block = 0; // 매 턴 초기화 (이전 턴 블록 미이월)
|
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) {
|
||||||
if (it.kind === 'Attack') {
|
if (it.kind === 'Attack') {
|
||||||
const atk = calcAttack(it.value, m.str, m.weak, pVuln);
|
const atk = calcAttack(it.value, m.str, m.weak, pVuln);
|
||||||
@@ -224,9 +230,12 @@ export function simulateCombat(data, rng, stats) {
|
|||||||
else if (it.kind === 'Debuff') {
|
else if (it.kind === 'Debuff') {
|
||||||
if (it.effect === 'weak') pWeak += it.value;
|
if (it.effect === 'weak') pWeak += it.value;
|
||||||
else if (it.effect === 'vuln') pVuln += 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 동기화 (자기 행동 후)
|
// 적 디버프 감소 — Lua EnemyActStep 동기화 (자기 행동 후)
|
||||||
if (m.weak > 0) m.weak--;
|
if (m.weak > 0) m.weak--;
|
||||||
if (m.vuln > 0) m.vuln--;
|
if (m.vuln > 0) m.vuln--;
|
||||||
|
|||||||
@@ -345,3 +345,33 @@ test('simulateCombat: draw — 카드 드로로 손패 보충', () => {
|
|||||||
assert.ok(r.turns <= 2, `seed ${s}: ${r.turns}턴`);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -90,8 +90,10 @@ function luaPotionsTable(potions) {
|
|||||||
|
|
||||||
function luaIntentsArray(intents) {
|
function luaIntentsArray(intents) {
|
||||||
return '{ ' + intents.map((it) => {
|
return '{ ' + intents.map((it) => {
|
||||||
const fields = [`kind = ${luaStr(it.kind)}`, `value = ${it.value}`];
|
const fields = [`kind = ${luaStr(it.kind)}`, `value = ${it.value != null ? it.value : 0}`];
|
||||||
if (it.effect != null) fields.push(`effect = ${luaStr(it.effect)}`);
|
if (it.effect != null) fields.push(`effect = ${luaStr(it.effect)}`);
|
||||||
|
if (it.card != null) fields.push(`card = ${luaStr(it.card)}`);
|
||||||
|
if (it.count != null) fields.push(`count = ${it.count}`);
|
||||||
return `{ ${fields.join(', ')} }`;
|
return `{ ${fields.join(', ')} }`;
|
||||||
}).join(', ') + ' }';
|
}).join(', ') + ' }';
|
||||||
}
|
}
|
||||||
@@ -131,6 +133,9 @@ function luaCardsTable(cards) {
|
|||||||
if (c.heal != null) fields.push(`heal = ${c.heal}`);
|
if (c.heal != null) fields.push(`heal = ${c.heal}`);
|
||||||
if (c.poison != null) fields.push(`poison = ${c.poison}`);
|
if (c.poison != null) fields.push(`poison = ${c.poison}`);
|
||||||
if (c.aoe === true) fields.push('aoe = true');
|
if (c.aoe === true) fields.push('aoe = true');
|
||||||
|
if (c.unplayable === true) fields.push('unplayable = true');
|
||||||
|
if (c.curse === true) fields.push('curse = true');
|
||||||
|
if (c.endTurnDamage != null) fields.push(`endTurnDamage = ${c.endTurnDamage}`);
|
||||||
if (c.image != null) fields.push(`image = ${luaStr(c.image)}`);
|
if (c.image != null) fields.push(`image = ${luaStr(c.image)}`);
|
||||||
return `\t${id} = { ${fields.join(', ')} },`;
|
return `\t${id} = { ${fields.join(', ')} },`;
|
||||||
});
|
});
|
||||||
@@ -2761,37 +2766,64 @@ for i = 1, #reg do
|
|||||||
reg[i].entity:SetVisible(false)
|
reg[i].entity:SetVisible(false)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local list = {}
|
local byGroup = {}
|
||||||
for i = 1, #reg do
|
for i = 1, #reg do
|
||||||
local r = reg[i]
|
local r = reg[i]
|
||||||
if r.entity ~= nil and isvalid(r.entity) and r.group == g and (r.map == nil or r.map == "" or pmap == "" or r.map == pmap) then
|
if r.entity ~= nil and isvalid(r.entity) and (r.map == nil or r.map == "" or pmap == "" or r.map == pmap) then
|
||||||
|
local gg = r.group
|
||||||
|
if gg == nil or gg == "" then gg = "combat" end
|
||||||
|
if byGroup[gg] == nil then byGroup[gg] = {} end
|
||||||
local x = 0
|
local x = 0
|
||||||
if r.entity.TransformComponent ~= nil then
|
if r.entity.TransformComponent ~= nil then
|
||||||
x = r.entity.TransformComponent.WorldPosition.x
|
x = r.entity.TransformComponent.WorldPosition.x
|
||||||
end
|
end
|
||||||
table.insert(list, { entity = r.entity, enemyId = r.enemyId, x = x })
|
table.insert(byGroup[gg], { entity = r.entity, enemyId = r.enemyId, x = x })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
table.sort(list, function(a, b) return a.x < b.x end)
|
-- 노드 타입별 랜덤 구성: 일반 1~3 / 엘리트 1+일반0~2 / 보스 1
|
||||||
|
local chosen = {}
|
||||||
|
local function takeFrom(key, k)
|
||||||
|
local src = byGroup[key] or {}
|
||||||
|
local pool = {}
|
||||||
|
for i = 1, #src do pool[i] = src[i] end
|
||||||
|
self:Shuffle(pool)
|
||||||
|
local taken = 0
|
||||||
|
for i = 1, #pool do
|
||||||
|
if taken >= k then break end
|
||||||
|
table.insert(chosen, pool[i])
|
||||||
|
taken = taken + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if g == "boss" then
|
||||||
|
takeFrom("boss", 1)
|
||||||
|
elseif g == "elite" then
|
||||||
|
takeFrom("elite", 1)
|
||||||
|
takeFrom("combat", math.random(0, 2))
|
||||||
|
else
|
||||||
|
takeFrom("combat", math.random(1, 3))
|
||||||
|
end
|
||||||
|
if #chosen == 0 then takeFrom(g, 1) end
|
||||||
|
if #chosen == 0 then takeFrom("combat", 1) end
|
||||||
|
table.sort(chosen, function(a, b) return a.x < b.x end)
|
||||||
local mult = 1 + (self.Floor - 1) * 0.45
|
local mult = 1 + (self.Floor - 1) * 0.45
|
||||||
if g == "elite" or g == "boss" then
|
if g == "elite" or g == "boss" then
|
||||||
mult = mult + self:AscEliteBonus()
|
mult = mult + self:AscEliteBonus()
|
||||||
end
|
end
|
||||||
local n = #list
|
local n = #chosen
|
||||||
if n > ${MAX_MONSTERS} then n = ${MAX_MONSTERS} end
|
if n > ${MAX_MONSTERS} then n = ${MAX_MONSTERS} end
|
||||||
for i = 1, n do
|
for i = 1, n do
|
||||||
local item = list[i]
|
local item = chosen[i]
|
||||||
local e = self.Enemies[item.enemyId]
|
local e = self.Enemies[item.enemyId]
|
||||||
if e == nil then e = { name = item.enemyId, maxHp = 10, intents = { { kind = "Attack", value = 5 } } } end
|
if e == nil then e = { name = item.enemyId, maxHp = 10, intents = { { kind = "Attack", value = 5 } } } end
|
||||||
local intents = {}
|
local intents = {}
|
||||||
for k = 1, #e.intents do
|
for k = 1, #e.intents do
|
||||||
local v = e.intents[k].value
|
local v = e.intents[k].value or 0
|
||||||
if e.intents[k].kind == "Attack" then
|
if e.intents[k].kind == "Attack" then
|
||||||
v = math.floor(v * mult * self:AscAtkMult())
|
v = math.floor(v * mult * self:AscAtkMult())
|
||||||
elseif e.intents[k].kind ~= "Debuff" then
|
elseif e.intents[k].kind ~= "Debuff" then
|
||||||
v = math.floor(v * mult)
|
v = math.floor(v * mult)
|
||||||
end
|
end
|
||||||
intents[k] = { kind = e.intents[k].kind, value = v, effect = e.intents[k].effect }
|
intents[k] = { kind = e.intents[k].kind, value = v, effect = e.intents[k].effect, card = e.intents[k].card, count = e.intents[k].count }
|
||||||
end
|
end
|
||||||
local maxHp = math.floor(e.maxHp * mult * self:AscHpMult())
|
local maxHp = math.floor(e.maxHp * mult * self:AscHpMult())
|
||||||
local hitClip = nil
|
local hitClip = nil
|
||||||
@@ -2802,10 +2834,12 @@ for i = 1, n do
|
|||||||
standClip = item.entity.StateAnimationComponent.ActionSheet["stand"]
|
standClip = item.entity.StateAnimationComponent.ActionSheet["stand"]
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
local startIdx = 1
|
||||||
|
if #intents > 0 then startIdx = math.random(1, #intents) end
|
||||||
self.Monsters[i] = { entity = item.entity, enemyId = item.enemyId, name = e.name,
|
self.Monsters[i] = { entity = item.entity, enemyId = item.enemyId, name = e.name,
|
||||||
hp = maxHp, maxHp = maxHp, block = 0, str = 0, weak = 0, vuln = 0, poison = 0,
|
hp = maxHp, maxHp = maxHp, block = 0, str = 0, weak = 0, vuln = 0, poison = 0,
|
||||||
hitClip = hitClip, standClip = standClip, motionBusy = false,
|
hitClip = hitClip, standClip = standClip, motionBusy = false,
|
||||||
intents = intents, intentIdx = 1, alive = true, slot = i }
|
intents = intents, intentIdx = startIdx, alive = true, slot = i }
|
||||||
self:ReviveMonsterEntity(item.entity)
|
self:ReviveMonsterEntity(item.entity)
|
||||||
self:PositionMonsterSlot(i)
|
self:PositionMonsterSlot(i)
|
||||||
end
|
end
|
||||||
@@ -3026,6 +3060,17 @@ self:RenderCombat()`),
|
|||||||
method('EndPlayerTurn', `if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
|
method('EndPlayerTurn', `if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
local burn = 0
|
||||||
|
for bi = 1, #self.Hand do
|
||||||
|
\tlocal hc = self.Cards[self.Hand[bi]]
|
||||||
|
\tif hc ~= nil and hc.endTurnDamage ~= nil then burn = burn + hc.endTurnDamage end
|
||||||
|
end
|
||||||
|
if burn > 0 then
|
||||||
|
\tself.PlayerHp = self.PlayerHp - burn
|
||||||
|
\tif self.PlayerHp < 0 then self.PlayerHp = 0 end
|
||||||
|
\tself:ShowPlayerDmgPop(burn)
|
||||||
|
\tself:RenderCombat()
|
||||||
|
end
|
||||||
for i = 1, #self.Hand do
|
for i = 1, #self.Hand do
|
||||||
\ttable.insert(self.DiscardPile, self.Hand[i])
|
\ttable.insert(self.DiscardPile, self.Hand[i])
|
||||||
end
|
end
|
||||||
@@ -3279,6 +3324,10 @@ local c = self.Cards[cardId]
|
|||||||
if c == nil then
|
if c == nil then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if c.unplayable == true then
|
||||||
|
self:Toast("사용할 수 없는 카드입니다")
|
||||||
|
return
|
||||||
|
end
|
||||||
if self.Energy < c.cost then
|
if self.Energy < c.cost then
|
||||||
self:Toast("에너지가 부족합니다")
|
self:Toast("에너지가 부족합니다")
|
||||||
return
|
return
|
||||||
@@ -3623,11 +3672,20 @@ _TimerService:SetTimerOnce(function()
|
|||||||
elseif intent.effect == "vuln" then
|
elseif intent.effect == "vuln" then
|
||||||
self.PlayerVuln = self.PlayerVuln + intent.value
|
self.PlayerVuln = self.PlayerVuln + intent.value
|
||||||
end
|
end
|
||||||
|
elseif intent.kind == "AddCard" then
|
||||||
|
local cnt = intent.count or 1
|
||||||
|
for ci = 1, cnt do
|
||||||
|
table.insert(self.DiscardPile, intent.card)
|
||||||
|
end
|
||||||
|
self:RenderPiles()
|
||||||
|
local cn = intent.card
|
||||||
|
local cc = self.Cards[intent.card]
|
||||||
|
if cc ~= nil then cn = cc.name end
|
||||||
|
self:Toast(m.name .. ": " .. cn .. " 추가!")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
m.intentIdx = m.intentIdx + 1
|
if #m.intents > 0 then
|
||||||
if m.intentIdx > #m.intents then
|
m.intentIdx = math.random(1, #m.intents)
|
||||||
m.intentIdx = 1
|
|
||||||
end
|
end
|
||||||
if m.weak > 0 then m.weak = m.weak - 1 end
|
if m.weak > 0 then m.weak = m.weak - 1 end
|
||||||
if m.vuln > 0 then m.vuln = m.vuln - 1 end
|
if m.vuln > 0 then m.vuln = m.vuln - 1 end
|
||||||
@@ -3828,6 +3886,8 @@ return table.concat(parts, " ")`, [
|
|||||||
elseif intent.kind == "Debuff" then
|
elseif intent.kind == "Debuff" then
|
||||||
if intent.effect == "weak" then t = "약화 " .. tostring(intent.value) .. " 부여"
|
if intent.effect == "weak" then t = "약화 " .. tostring(intent.value) .. " 부여"
|
||||||
else t = "취약 " .. tostring(intent.value) .. " 부여" end
|
else t = "취약 " .. tostring(intent.value) .. " 부여" end
|
||||||
|
elseif intent.kind == "AddCard" then
|
||||||
|
t = "저주 카드 추가"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
self:SetText(base .. "/Intent", t)
|
self:SetText(base .. "/Intent", t)
|
||||||
@@ -3838,6 +3898,8 @@ return table.concat(parts, " ")`, [
|
|||||||
intentEntity.TextComponent.FontColor = Color(1, 0.45, 0.35, 1)
|
intentEntity.TextComponent.FontColor = Color(1, 0.45, 0.35, 1)
|
||||||
elseif intent.kind == "Debuff" then
|
elseif intent.kind == "Debuff" then
|
||||||
intentEntity.TextComponent.FontColor = Color(0.8, 0.5, 1, 1)
|
intentEntity.TextComponent.FontColor = Color(0.8, 0.5, 1, 1)
|
||||||
|
elseif intent.kind == "AddCard" then
|
||||||
|
intentEntity.TextComponent.FontColor = Color(0.6, 0.85, 0.4, 1)
|
||||||
else
|
else
|
||||||
intentEntity.TextComponent.FontColor = Color(0.5, 0.75, 1, 1)
|
intentEntity.TextComponent.FontColor = Color(0.5, 0.75, 1, 1)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,15 +2,24 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|||||||
|
|
||||||
// map02~11에 노드 타입별 몬스터 그룹(combat3/elite2/boss1)을 맵별 테마로 자동 구성.
|
// map02~11에 노드 타입별 몬스터 그룹(combat3/elite2/boss1)을 맵별 테마로 자동 구성.
|
||||||
// 기존 몬스터 엔티티를 전부 제거하고 첫 몬스터를 템플릿으로 6마리 재생성(결정론).
|
// 기존 몬스터 엔티티를 전부 제거하고 첫 몬스터를 템플릿으로 6마리 재생성(결정론).
|
||||||
const MAP_NUMBERS = [2, 3, 4, 5];
|
const MAP_NUMBERS = [1, 2, 3, 4, 5];
|
||||||
const COMBAT_POOL = ['orange_mushroom', 'green_mushroom', 'pig', 'blue_mushroom'];
|
const COMBAT_POOL = ['orange_mushroom', 'green_mushroom', 'pig', 'blue_mushroom', 'red_snail', 'stump'];
|
||||||
const ELITE_POOL = ['mushmom', 'modified_snail'];
|
const ELITE_POOL = ['mushmom', 'modified_snail'];
|
||||||
const BOSS_POOL = ['king_slime', 'slime_boss'];
|
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: '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: 'elite', x: 3.0 }, { group: 'elite', x: 5.0 },
|
||||||
{ group: 'boss', x: 4.0 },
|
{ group: 'boss', x: 4.0 },
|
||||||
];
|
];
|
||||||
|
const layoutFor = (nn) => (nn === 1 ? LAYOUT_MAP01 : LAYOUT_DEFAULT);
|
||||||
const MONSTER_VARIANTS = [
|
const MONSTER_VARIANTS = [
|
||||||
{ sprite: '96e955c1bf27415e84f96deea200a8f1', stand: '96e955c1bf27415e84f96deea200a8f1', hit: 'aec9504d5dc24aceb5646b79d30abad4', die: '65a2bfb039614f2e9e4ccc354340153d' },
|
{ sprite: '96e955c1bf27415e84f96deea200a8f1', stand: '96e955c1bf27415e84f96deea200a8f1', hit: 'aec9504d5dc24aceb5646b79d30abad4', die: '65a2bfb039614f2e9e4ccc354340153d' },
|
||||||
{ sprite: 'f86992ba9c41487c8480fcb893fcbda6', stand: 'f86992ba9c41487c8480fcb893fcbda6', hit: 'd305b942b1704c8084548108ff3b7a6b', die: '5a563e5fd98c4132b61057dc6bb8aaf2' },
|
{ sprite: 'f86992ba9c41487c8480fcb893fcbda6', stand: 'f86992ba9c41487c8480fcb893fcbda6', hit: 'd305b942b1704c8084548108ff3b7a6b', die: '5a563e5fd98c4132b61057dc6bb8aaf2' },
|
||||||
@@ -54,13 +63,17 @@ function patchMap(nn) {
|
|||||||
const template = monsters[0];
|
const template = monsters[0];
|
||||||
map.ContentProto.Entities = ents.filter((e) => !isMonster(e));
|
map.ContentProto.Entities = ents.filter((e) => !isMonster(e));
|
||||||
const rand = rng(nn * 7919 + 17);
|
const rand = rng(nn * 7919 + 17);
|
||||||
const combatIds = pickN(rand, COMBAT_POOL, 3);
|
const layout = layoutFor(nn);
|
||||||
const eliteIds = pickN(rand, ELITE_POOL, 2);
|
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 bossId = pick(rand, BOSS_POOL);
|
||||||
const variants = pickN(rand, MONSTER_VARIANTS, 6);
|
const variants = pickN(rand, MONSTER_VARIANTS, layout.length);
|
||||||
LAYOUT.forEach((slot, idx) => {
|
let ci = 0, ei = 0;
|
||||||
|
layout.forEach((slot, idx) => {
|
||||||
const m = JSON.parse(JSON.stringify(template));
|
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}`;
|
const name = `${slot.group}_${idx + 1}`;
|
||||||
m.id = encGuid(nn, idx);
|
m.id = encGuid(nn, idx);
|
||||||
m.path = `/maps/map${tag}/${name}`;
|
m.path = `/maps/map${tag}/${name}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user