강·안개 개정(PD 2026-11-06 #1101) — 강 = 늪처럼 통과(강 안 ×2 · 다리 = 효과 없음 · 기병 넉백 0) · 강가·다리에서 밀려 강물에 들면 30 % + 1턴 · 안개 = 원거리 피해(화살·책사) ×0.5 · 표적 정상 · 책사 광역이 그 반경 안개를 걷음

- gimmicks.csv river(kind zone · p3 2.0) · fog(p1 = 원거리 피해 배율) · bridge(desc) · 강 콜라이더 조각 폐기
- 강제 이동 기록 = 구간(From·To) → 강물 진입 판정 · Combat.Damage 원거리 = 안개 ×p1 · 안개 걷힘 = SpriteMask
- 테스트 113/113(강·안개 2건 새 규칙) · console error 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-11-06 01:42:15 +09:00
parent 98f675dfda
commit 8a7c565609
12 changed files with 211 additions and 156 deletions

View File

@ -7,11 +7,11 @@ thorn_bush,가시덤불,block,0,0,0,0,0,0,0.05,0.05,block,1,,,,,gim_thorn,,통
ice,빙판,zone,0,0,0,0,0,0,0,0,pass,0,slide,3.0,1.5,,gim_ice,,정지 지점이 빙판이면 같은 방향으로 빙판 끝까지(최대 p1) 미끄러짐 · 충돌 규칙 그대로 · 기병 빙판 위 밀기 ×p2 [p1 최대 거리 · p2 기병 밀기 배율]
boulder,바위,block,0,0,0,0,0,0.5,0.05,0,block,0,,1.0,1.0,,gim_boulder,,보·창이 밀면 바위 이동(남은 거리 × spear_push) · 밀린 바위가 적에 닿으면 공 ×p1 피해 + 밀기 p2 · 기병 충돌 정지·5 % · 투사체 막힘 · 파괴 불가 [p1 피해 배율 · p2 밀기]
high_ground,고지,zone,0,0,0,0,0,0,0,0,pass,0,high,1.0,0.15,1.5,gim_high,,위에 선 궁·책 사거리 +p1 · 아래에서 오는 근접 피해 −p2 · 오르는 경로 이동량 ×p3 [p1 사거리 · p2 피해 감소 · p3 이동 비용]
fog,안개,zone,0,0,0,0,0,0,0,0,pass,0,fog,0.5,0,,gim_fog,,안개 안 유닛은 사선 표적 제외(안에서 쏘기는 가능) · 근접 정상 · 광역 중심이 안개면 범위 ×p1 [p1 광역 배율]
fog,안개,zone,0,0,0,0,0,0,0,0,pass,0,fog,0.5,0,,gim_fog,,안개 안 유닛이 받는 원거리 피해(화살·책사 광역) ×p1 · 표적·사선·근접은 정상 · 책사 광역이 터진 반경만큼 안개가 걷힘(PD 2026-11-06 개정) [p1 원거리 피해 배율]
powder_cart,폭약 수레,block,1,1,1,1,1,0,0,0,destroy,1,,2.0,1.5,1.0,gim_cart,gim_cart_broken,내구 1 · 어떤 공격·화살에도 파괴 · 파괴 시 반경 p1 광역 공 ×p2(아군 포함) + 밀기 p3 · 불타는 잔해 생성 [p1 반경 · p2 피해 배율 · p3 밀기]
banner,군기,zone,0,0,0,0,0,0,0,0,pass,0,capture,1.5,0.1,,gim_banner,,반경 p1 안에 한쪽 편만 있으면 턴 시작 시 점령 → 점령 측 전원 공 +p2 · 양쪽 있으면 중립 [p1 반경 · p2 공 증가]
gate,성문,block,1,6,2,1,1,0,0.1,0,block,0,,,,,gim_gate,gim_gate_broken,내구 6 · 보 2 · 창 1(밀기 0) · 기 1 + 자기 HP 10 % · 화살·광역 막힘 · 파괴 시 통로 열림(성벽 구간은 wall)
wall,성벽,block,0,0,0,0,0,0,0.05,0,block,0,,,,,gim_wall,,파괴 불가 · 부딪히면 튕김(기병 5 %) · 투사체 막힘 · 성문과 짝
river,강,block,0,0,0,0,0,0,0,0,pass,0,river,0.3,1,,gim_river,,통과 불가(투사체 통과) · 다리 위에서 밀려 강에 빠지면 최대 HP p1 + p2 턴 행동 불가 · 다리 = bridge 레코드가 겹친 구간만 통행 [p1 피해 · p2 턴]
bridge,다리,zone,0,0,0,0,0,0,0,0,pass,0,,1.2,,,gim_bridge,,강 위 통로(폭 p1) · 다리 위에서 옆으로 밀리면 river 판정 [p1 폭]
river,강,zone,0,0,0,0,0,0,0,0,pass,0,river,0.3,1,2.0,gim_river,,늪처럼 통과 가능 · 강 안 구간 이동량 ×p3 · 기병 넉백 강 안 0 · 밀려서(강가·다리) 강에 들어가면 최대 HP p1 + p2 턴 행동 불가 · 걸어 들어가면 느려지기만 · 다리 구간 = 효과 없음 · 투사체 통과(PD 2026-11-06 개정) [p1 피해 · p2 턴 · p3 이동 비용]
bridge,다리,zone,0,0,0,0,0,0,0,0,pass,0,,1.2,,,gim_bridge,,강 위 통로(폭 p1) · 다리 위 = 강 효과 없음(이동 비용·빠짐 없음) · 다리에서 옆으로 밀려 강에 들어가면 빠짐 [p1 폭]
pit_trap,함정,zone,0,0,0,0,0,0,0,0,pass,0,trap,1,,,gim_trap,gim_trap_open,숨김(아군 조준선 미표시 · 적 AI 는 인지) · 밟으면 그 자리 정지 + p1 턴 행동 불가 · 발동 후 제거 [p1 턴]

1 id name_ko kind durable hp dmg_inf dmg_spear dmg_cav spear_push cav_self_pct bounce_pct projectile strategist_clear zone_effect p1 p2 p3 art art_broken desc_ko
7 ice 빙판 zone 0 0 0 0 0 0 0 0 pass 0 slide 3.0 1.5 gim_ice 정지 지점이 빙판이면 같은 방향으로 빙판 끝까지(최대 p1) 미끄러짐 · 충돌 규칙 그대로 · 기병 빙판 위 밀기 ×p2 [p1 최대 거리 · p2 기병 밀기 배율]
8 boulder 바위 block 0 0 0 0 0 0.5 0.05 0 block 0 1.0 1.0 gim_boulder 보·창이 밀면 바위 이동(남은 거리 × spear_push) · 밀린 바위가 적에 닿으면 공 ×p1 피해 + 밀기 p2 · 기병 충돌 정지·5 % · 투사체 막힘 · 파괴 불가 [p1 피해 배율 · p2 밀기]
9 high_ground 고지 zone 0 0 0 0 0 0 0 0 pass 0 high 1.0 0.15 1.5 gim_high 위에 선 궁·책 사거리 +p1 · 아래에서 오는 근접 피해 −p2 · 오르는 경로 이동량 ×p3 [p1 사거리 · p2 피해 감소 · p3 이동 비용]
10 fog 안개 zone 0 0 0 0 0 0 0 0 pass 0 fog 0.5 0 gim_fog 안개 안 유닛은 사선 표적 제외(안에서 쏘기는 가능) · 근접 정상 · 광역 중심이 안개면 범위 ×p1 [p1 광역 배율] 안개 안 유닛이 받는 원거리 피해(화살·책사 광역) ×p1 · 표적·사선·근접은 정상 · 책사 광역이 터진 반경만큼 안개가 걷힘(PD 2026-11-06 개정) [p1 원거리 피해 배율]
11 powder_cart 폭약 수레 block 1 1 1 1 1 0 0 0 destroy 1 2.0 1.5 1.0 gim_cart gim_cart_broken 내구 1 · 어떤 공격·화살에도 파괴 · 파괴 시 반경 p1 광역 공 ×p2(아군 포함) + 밀기 p3 · 불타는 잔해 생성 [p1 반경 · p2 피해 배율 · p3 밀기]
12 banner 군기 zone 0 0 0 0 0 0 0 0 pass 0 capture 1.5 0.1 gim_banner 반경 p1 안에 한쪽 편만 있으면 턴 시작 시 점령 → 점령 측 전원 공 +p2 · 양쪽 있으면 중립 [p1 반경 · p2 공 증가]
13 gate 성문 block 1 6 2 1 1 0 0.1 0 block 0 gim_gate gim_gate_broken 내구 6 · 보 2 · 창 1(밀기 0) · 기 1 + 자기 HP 10 % · 화살·광역 막힘 · 파괴 시 통로 열림(성벽 구간은 wall)
14 wall 성벽 block 0 0 0 0 0 0 0.05 0 block 0 gim_wall 파괴 불가 · 부딪히면 튕김(기병 5 %) · 투사체 막힘 · 성문과 짝
15 river 강 block zone 0 0 0 0 0 0 0 0 pass 0 river 0.3 1 2.0 gim_river 통과 불가(투사체 통과) · 다리 위에서 밀려 강에 빠지면 최대 HP p1 + p2 턴 행동 불가 · 다리 = bridge 레코드가 겹친 구간만 통행 [p1 피해 · p2 턴] 늪처럼 통과 가능 · 강 안 구간 이동량 ×p3 · 기병 넉백 강 안 0 · 밀려서(강가·다리) 강에 들어가면 최대 HP p1 + p2 턴 행동 불가 · 걸어 들어가면 느려지기만 · 다리 구간 = 효과 없음 · 투사체 통과(PD 2026-11-06 개정) [p1 피해 · p2 턴 · p3 이동 비용]
16 bridge 다리 zone 0 0 0 0 0 0 0 0 pass 0 1.2 gim_bridge 강 위 통로(폭 p1) · 다리 위에서 옆으로 밀리면 river 판정 [p1 폭] 강 위 통로(폭 p1) · 다리 위 = 강 효과 없음(이동 비용·빠짐 없음) · 다리에서 옆으로 밀려 강에 들어가면 빠짐 [p1 폭]
17 pit_trap 함정 zone 0 0 0 0 0 0 0 0 pass 0 trap 1 gim_trap gim_trap_open 숨김(아군 조준선 미표시 · 적 AI 는 인지) · 밟으면 그 자리 정지 + p1 턴 행동 불가 · 발동 후 제거 [p1 턴]

View File

@ -218,10 +218,11 @@ MonoBehaviour:
p3: NaN
art: gim_fog
artBroken:
descKo: "\uC548\uAC1C \uC548 \uC720\uB2DB\uC740 \uC0AC\uC120 \uD45C\uC801 \uC81C\uC678(\uC548\uC5D0\uC11C
\uC3D8\uAE30\uB294 \uAC00\uB2A5) \xB7 \uADFC\uC811 \uC815\uC0C1 \xB7 \uAD11\uC5ED
\uC911\uC2EC\uC774 \uC548\uAC1C\uBA74 \uBC94\uC704 \xD7p1 [p1 \uAD11\uC5ED
\uBC30\uC728]"
descKo: "\uC548\uAC1C \uC548 \uC720\uB2DB\uC774 \uBC1B\uB294 \uC6D0\uAC70\uB9AC
\uD53C\uD574(\uD654\uC0B4\xB7\uCC45\uC0AC \uAD11\uC5ED) \xD7p1 \xB7 \uD45C\uC801\xB7\uC0AC\uC120\xB7\uADFC\uC811\uC740
\uC815\uC0C1 \xB7 \uCC45\uC0AC \uAD11\uC5ED\uC774 \uD130\uC9C4 \uBC18\uACBD\uB9CC\uD07C
\uC548\uAC1C\uAC00 \uAC77\uD798(PD 2026-11-06 \uAC1C\uC815) [p1 \uC6D0\uAC70\uB9AC
\uD53C\uD574 \uBC30\uC728]"
- id: powder_cart
nameKo: "\uD3ED\uC57D \uC218\uB808"
kind: 0
@ -314,7 +315,7 @@ MonoBehaviour:
5 %) \xB7 \uD22C\uC0AC\uCCB4 \uB9C9\uD798 \xB7 \uC131\uBB38\uACFC \uC9DD"
- id: river
nameKo: "\uAC15"
kind: 0
kind: 1
durable: 0
hp: 0
dmgInf: 0
@ -328,13 +329,16 @@ MonoBehaviour:
zone: 9
p1: 0.3
p2: 1
p3: NaN
p3: 2
art: gim_river
artBroken:
descKo: "\uD1B5\uACFC \uBD88\uAC00(\uD22C\uC0AC\uCCB4 \uD1B5\uACFC) \xB7 \uB2E4\uB9AC
\uC704\uC5D0\uC11C \uBC00\uB824 \uAC15\uC5D0 \uBE60\uC9C0\uBA74 \uCD5C\uB300
HP p1 + p2 \uD134 \uD589\uB3D9 \uBD88\uAC00 \xB7 \uB2E4\uB9AC = bridge \uB808\uCF54\uB4DC\uAC00
\uACB9\uCE5C \uAD6C\uAC04\uB9CC \uD1B5\uD589 [p1 \uD53C\uD574 \xB7 p2 \uD134]"
descKo: "\uB2AA\uCC98\uB7FC \uD1B5\uACFC \uAC00\uB2A5 \xB7 \uAC15 \uC548 \uAD6C\uAC04
\uC774\uB3D9\uB7C9 \xD7p3 \xB7 \uAE30\uBCD1 \uB109\uBC31 \uAC15 \uC548 0 \xB7
\uBC00\uB824\uC11C(\uAC15\uAC00\xB7\uB2E4\uB9AC) \uAC15\uC5D0 \uB4E4\uC5B4\uAC00\uBA74
\uCD5C\uB300 HP p1 + p2 \uD134 \uD589\uB3D9 \uBD88\uAC00 \xB7 \uAC78\uC5B4
\uB4E4\uC5B4\uAC00\uBA74 \uB290\uB824\uC9C0\uAE30\uB9CC \xB7 \uB2E4\uB9AC \uAD6C\uAC04
= \uD6A8\uACFC \uC5C6\uC74C \xB7 \uD22C\uC0AC\uCCB4 \uD1B5\uACFC(PD 2026-11-06
\uAC1C\uC815) [p1 \uD53C\uD574 \xB7 p2 \uD134 \xB7 p3 \uC774\uB3D9 \uBE44\uC6A9]"
- id: bridge
nameKo: "\uB2E4\uB9AC"
kind: 1
@ -354,8 +358,10 @@ MonoBehaviour:
p3: NaN
art: gim_bridge
artBroken:
descKo: "\uAC15 \uC704 \uD1B5\uB85C(\uD3ED p1) \xB7 \uB2E4\uB9AC \uC704\uC5D0\uC11C
\uC606\uC73C\uB85C \uBC00\uB9AC\uBA74 river \uD310\uC815 [p1 \uD3ED]"
descKo: "\uAC15 \uC704 \uD1B5\uB85C(\uD3ED p1) \xB7 \uB2E4\uB9AC \uC704 = \uAC15
\uD6A8\uACFC \uC5C6\uC74C(\uC774\uB3D9 \uBE44\uC6A9\xB7\uBE60\uC9D0 \uC5C6\uC74C)
\xB7 \uB2E4\uB9AC\uC5D0\uC11C \uC606\uC73C\uB85C \uBC00\uB824 \uAC15\uC5D0
\uB4E4\uC5B4\uAC00\uBA74 \uBE60\uC9D0 [p1 \uD3ED]"
- id: pit_trap
nameKo: "\uD568\uC815"
kind: 1

View File

@ -315,7 +315,7 @@ namespace TKS
{
// 반작용: 벽·유닛·장애물에 닿으면 정지 · 피해 없음 · 보는 방향은 유지(밀친 쪽을 본 채 물러남)
float back = Combat.FreeTravel(u, u.Position, -dir, recoil, true, true, first);
NoteForced(u, -dir, recoil, back);
NoteForced(u, u.Position, u.Position - dir * back);
if (back > 0.01f)
{
Vector2 start = u.Position;
@ -826,7 +826,7 @@ namespace TKS
if (self >= 0.01f && striker.IsAlive)
{
float back = Combat.FreeTravel(striker, striker.Position, -n, self, true, true, struck);
NoteForced(striker, -n, self, back);
NoteForced(striker, striker.Position, striker.Position - n * back);
if (back > 0.01f)
{
Face(striker, -n); // 튕겨 나온 방향을 본다(PD #1082)
@ -852,7 +852,7 @@ namespace TKS
if (pusher != null && pusher.Faction != target.Faction) BattleEvents.Raise(BattleEvents.Pushes, pusher); // 보물 조건 효과(PD #1093 C)
force = Reach(target, target.Position, n, force); // 늪 = 밀리는 거리도 이동 비용(PD #1097)
float slide = Combat.FreeTravel(target, target.Position, n, force, true, true, pusher);
NoteForced(target, n, force, slide); // 막힘 기록 → 다리 위에서 강 쪽이면 강에 빠짐
NoteForced(target, target.Position, target.Position + n * slide); // 강제 이동 기록 → 강가·다리에서 밀려 강물에 들면 빠짐
float moved = 0f;
if (slide > 0.01f)
{

View File

@ -31,6 +31,7 @@ namespace TKS
if (UnitTypeInfo.IsRanged(defender.Type) && UnitTypeInfo.IsMelee(attacker.Type) && !SkillMods.Has(defender, SkillKeys.MeleePenaltyOff, true)) raw *= cfg.archerMeleeTakenRatio;
raw *= GimmickLayer.MeleeTakenMultOf(attacker, defender); // 고지 위 = 아래에서 오는 근접 피해 −(PD #1097)
}
else raw *= GimmickLayer.RangedTakenMultOf(defender); // 안개 안 = 받는 원거리 피해 ×p1(화살 · 책사 중심·광역 · PD 2026-11-06 개정)
raw *= scale * SkillMods.DealtMult(attacker, defender, true) * SkillMods.TakenMult(defender, attacker, true);
return Mathf.Max(1, Mathf.RoundToInt(raw * Variance(cfg, rng))); // 가상 실행 = 분산 0(PD #1098)
}
@ -86,7 +87,6 @@ namespace TKS
{
var u = UnitOf(h.collider);
if (u == null || u == actor || !u.IsAlive || u.Faction == actor.Faction) continue;
if (GimmickLayer.IsFogged(u)) continue; // 안개 안 = 사선 표적 제외(PD #1097)
hitDist = h.distance;
return u;
}

View File

@ -41,9 +41,9 @@ namespace TKS
return Gim ? Layer.CostReach(u, origin, dir, budget) : budget;
}
static void NoteForced(Unit u, Vector2 dir, float intended, float actual)
static void NoteForced(Unit u, Vector2 from, Vector2 to)
{
if (Gim) Layer.NoteForced(u, dir, intended, actual);
if (Gim) Layer.NoteForced(u, from, to);
}
void GimmickBegin(ActionOutcome o)
@ -285,7 +285,7 @@ namespace TKS
return Mathf.Max(0f, min - Combat.Epsilon);
}
/// <summary>기병 관통 옆 밀기: 늪 안 = 0 · 빙판 위 = ×p2 · 장애물·함정에서 멈춤 · 다리 위에서 강 쪽이면 강 판정.</summary>
/// <summary>기병 관통 옆 밀기: 늪·강 안 = 0 · 빙판 위 = ×p2 · 장애물·함정에서 멈춤 · 강가·다리에서 강물로 밀리면 빠짐.</summary>
Vector2 KnockTo(Unit e, Vector2 dir, float kb)
{
if (Gim) kb *= Layer.KnockMult(e);
@ -297,13 +297,13 @@ namespace TKS
Vector2 n;
if (Combat.FirstGimmickOnPath(e, e.Position, dir, want, out h, out n) != null) free = Mathf.Max(0f, h - Combat.Epsilon);
free = Mathf.Min(free, Layer.TrapStop(e, e.Position, dir, want));
NoteForced(e, dir, kb, free);
NoteForced(e, e.Position, e.Position + dir * free);
return e.Position + dir * free;
}
// ---------- 투사체 · 책사 광역 ----------
/// <summary>책사 광역 계획: 중심 = 첫 피격 유닛 · 유닛이 없고 제거 가능 기믹(덤불·수레)에 막혔으면 그 자리 · 반경 × 안개 · 성문·성벽 너머 제외 · 지울 기믹.</summary>
/// <summary>책사 광역 계획: 중심 = 첫 피격 유닛 · 유닛이 없고 제거 가능 기믹(덤불·수레)에 막혔으면 그 자리 · 성문·성벽 너머 제외 · 지울 기믹.</summary>
void AimSplash(Unit u, ShotPlan p)
{
p.Splash.Clear();
@ -313,7 +313,7 @@ namespace TKS
else if (p.HitGimmick != null && p.HitGimmick.Def.strategistClear) p.SplashCenter = p.To;
else return;
p.HasSplash = true;
p.SplashRadius = SkillMods.SplashRadius(u, cfg) * (Gim ? Layer.FogSplashMult(p.SplashCenter) : 1f);
p.SplashRadius = SkillMods.SplashRadius(u, cfg); // 안개 = 반경은 그대로 · 원거리 피해 ×p1(Combat.Damage · PD 2026-11-06 개정)
p.Splash.AddRange(Combat.UnitsInCircle(p.SplashCenter, p.SplashRadius, p.Hit, u));
if (!Gim) return;
p.Splash.RemoveAll(s => Layer.SplashBlocked(p.SplashCenter, s.Position));
@ -333,7 +333,7 @@ namespace TKS
}
}
/// <summary>책사 광역의 기믹: 덤불 제거 · 수레 파괴(폭발) · 화공 스테이지 = 그 자리 불.</summary>
/// <summary>책사 광역의 기믹: 덤불 제거 · 수레 파괴(폭발) · 그 반경만큼 안개 걷힘(PD 2026-11-06) · 화공 스테이지 = 그 자리 불.</summary>
IEnumerator SplashGimmicks(Unit u, ShotPlan p, ActionOutcome o)
{
if (!Gim || !p.HasSplash) yield break;
@ -347,6 +347,7 @@ namespace TKS
o.Notes.Add(g.Def.nameKo + " 제거");
}
}
if (Layer.ClearFog(p.SplashCenter, p.SplashRadius)) o.Notes.Add("안개 걷힘");
if (Layer.OnSplash(p.SplashCenter, p.SplashRadius) != null) o.Notes.Add("불길");
}

View File

@ -36,7 +36,7 @@ namespace TKS
/// <summary>
/// 투사체(PD 2026-11-05 #1087 ⑥ + 기믹 #1097): 첫 유닛 또는 막는 기믹. 목책·강 = 통과(pass) · 덤불·바위·성문·성벽 = 막힘(gimmick 에 담고 null) ·
/// 폭약 수레 = 맞으면 파괴(gimmick 에 담고 null) · 안개 안 유닛 = 사선 판정 제외(통과).
/// 폭약 수레 = 맞으면 파괴(gimmick 에 담고 null) · 안개 안 유닛도 정상 피격(피해만 ×p1 · PD 2026-11-06 개정).
/// </summary>
public static Unit FirstUnitOnArrow(Unit shooter, Vector2 from, Vector2 dir, float dist, float arrowRadius, Faction? passFaction, out float travel, out GimmickInstance gimmick)
{
@ -63,7 +63,6 @@ namespace TKS
var u = UnitOf(h.collider);
if (u == null || u == shooter || !u.IsAlive) continue;
if (passFaction.HasValue && u.Faction == passFaction.Value) continue;
if (GimmickLayer.IsFogged(u)) continue;
travel = h.distance;
return u;
}

View File

@ -190,9 +190,10 @@ namespace TKS
IEnumerator BridgePushShot(BattleManager bm, Camera cam, CameraController cc)
{
yield return Stage(bm);
var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, 0.8f), "보병");
bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0.15f, 3f), "다리 위 적");
yield return AimCapture(bm, cam, cc, u, new Vector2(0f, 3.2f), new Vector2(0f, 2.2f), "aim_9-11_bridge_push");
var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(-0.6f, 0.8f), "보병");
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0.35f, 3f), "다리 위 적");
Vector2 n = (foe.Position - u.Position).normalized;
yield return AimCapture(bm, cam, cc, u, u.Position + n * 4f, new Vector2(0f, 2.2f), "aim_9-11_bridge_push");
}
/// <summary>AI 전술: 적 보병이 아군을 함정 쪽으로 튕겨낸다(함정 발동 순간 캡처).</summary>

View File

@ -47,14 +47,17 @@ namespace TKS
public struct ForcedMove
{
public Unit Unit;
public Vector2 Dir;
public float Intended;
public float Actual;
public Vector2 From;
public Vector2 To;
}
/// <summary>이번 행동에서 밀림·튕김이 막힌 기록(다리 → 강 판정).</summary>
/// <summary>이번 행동의 강제 이동(밀림·튕김·반작용·넉백) 구간 — 강가·다리에서 밀려 강에 들어가면 빠짐(PD 2026-11-06 개정).</summary>
public readonly List<ForcedMove> Forced = new List<ForcedMove>();
/// <summary>책사 광역이 걷어 낸 안개 자리(x, y, 반지름 · PD 2026-11-06).</summary>
readonly List<Vector3> fogHoles = new List<Vector3>();
readonly List<GameObject> fogMasks = new List<GameObject>();
public struct Affect
{
public Unit Unit;
@ -115,7 +118,6 @@ namespace TKS
if (def == null) continue;
Spawn(def, p.Clone(), false);
}
RebuildRivers();
ResolveOverlaps();
}
@ -178,6 +180,12 @@ namespace TKS
states.Clear();
splashTemplate = null;
Forced.Clear();
fogHoles.Clear();
foreach (var m in fogMasks)
{
if (m != null) Destroy(m);
}
fogMasks.Clear();
Outcome = null;
if (Preview != null) Preview.Hide();
}
@ -231,9 +239,7 @@ namespace TKS
else if (k == "p3" || k == "spawn_on_splash") p.pOver3 = v;
}
}
var g = Spawn(def, p, false);
if (def.zone == GimmickZone.River || def.id == GimmickIds.Bridge) RebuildRivers();
return g;
return Spawn(def, p, false);
}
void BuildObject(GimmickInstance g)
@ -283,82 +289,6 @@ namespace TKS
g.Colliders.Add(col);
}
/// <summary>강 콜라이더 = 강 사각형에서 다리가 겹친 구간(강 가로축 투영)을 뺀 조각들. 원·다각형 강은 다리 없이 통째로.</summary>
void RebuildRivers()
{
if (Sim.Active) return;
foreach (var r in items)
{
if (r.Zone != GimmickZone.River || r.Go == null) continue;
foreach (var c in r.Colliders)
{
if (c == null) continue;
c.gameObject.SetActive(false); // 콜라이더를 즉시 물리 월드에서 제거
Destroy(c.gameObject);
}
r.Colliders.Clear();
if (r.Shape.Type != GimmickShapeType.Rect)
{
AddCollider(r, r.Shape, r.Go.transform);
continue;
}
float half = r.Shape.Size.x * 0.5f;
var cuts = new List<Vector2>();
Vector2 ax = r.Shape.LocalAxisX;
foreach (var b in items)
{
if (!b.IsBridge || b.Removed) continue;
if (r.Shape.SignedDistance(b.Shape.Center) > b.Shape.BoundingRadius) continue;
float lo = float.MaxValue, hi = float.MinValue;
foreach (var corner in Corners(b.Shape))
{
float x = Vector2.Dot(corner - r.Shape.Center, ax);
lo = Mathf.Min(lo, x);
hi = Mathf.Max(hi, x);
}
cuts.Add(new Vector2(Mathf.Max(lo, -half), Mathf.Min(hi, half)));
}
cuts.Sort((a, b) => a.x.CompareTo(b.x));
float cur = -half;
var pieces = new List<Vector2>();
foreach (var c in cuts)
{
if (c.x > cur + 0.01f) pieces.Add(new Vector2(cur, c.x));
cur = Mathf.Max(cur, c.y);
}
if (half > cur + 0.01f) pieces.Add(new Vector2(cur, half));
foreach (var pc in pieces)
{
float mid = (pc.x + pc.y) * 0.5f;
var s = GimmickShape.Box(r.Shape.Center + ax * mid, new Vector2(pc.y - pc.x, r.Shape.Size.y), r.Shape.Rot);
AddCollider(r, s, r.Go.transform);
}
}
}
static IEnumerable<Vector2> Corners(GimmickShape s)
{
if (s.Type == GimmickShapeType.Circle)
{
yield return s.Center + new Vector2(-s.R, -s.R);
yield return s.Center + new Vector2(s.R, s.R);
yield return s.Center + new Vector2(-s.R, s.R);
yield return s.Center + new Vector2(s.R, -s.R);
yield break;
}
if (s.Type == GimmickShapeType.Poly)
{
foreach (var p in s.Points) yield return p;
yield break;
}
Vector2 ax = s.LocalAxisX, ay = new Vector2(-ax.y, ax.x);
Vector2 hx = ax * s.Size.x * 0.5f, hy = ay * s.Size.y * 0.5f;
yield return s.Center + hx + hy;
yield return s.Center + hx - hy;
yield return s.Center - hx + hy;
yield return s.Center - hx - hy;
}
// ---------- 정적 질의(레이어가 없으면 기본값) ----------
public static GimmickInstance InstOf(Collider2D c)
@ -399,6 +329,12 @@ namespace TKS
return l != null && l.Fogged(u);
}
public static float RangedTakenMultOf(Unit d)
{
var l = Current;
return l == null ? 1f : l.RangedTakenMult(d);
}
public static bool Skips(Unit u)
{
var l = Current;
@ -439,6 +375,53 @@ namespace TKS
return false;
}
/// <summary>강물 안(강 지대 · 다리 구간 제외)이면 그 강.</summary>
public GimmickInstance RiverAt(Vector2 p)
{
var r = ZoneAt(p, GimmickZone.River);
return r != null && !OnBridge(p) ? r : null;
}
/// <summary>안개 안(책사 광역이 걷어 낸 자리 제외)이면 그 안개.</summary>
public GimmickInstance FogAt(Vector2 p)
{
var f = ZoneAt(p, GimmickZone.Fog);
if (f == null) return null;
foreach (var h in fogHoles)
{
if (Vector2.Distance(p, h) <= h.z) return null;
}
return f;
}
public int FogHoleCount
{
get { return fogHoles.Count; }
}
/// <summary>책사 광역 = 그 반경만큼 안개를 걷는다(겹친 안개가 있을 때만 · 반환 = 걷었다 · PD 2026-11-06).</summary>
public bool ClearFog(Vector2 c, float r)
{
bool any = false;
foreach (var g in items)
{
if (g.Zone == GimmickZone.Fog && g.ZoneActive && g.Shape.SignedDistance(c) <= r) any = true;
}
if (!any || r <= 0f) return false;
fogHoles.Add(new Vector3(c.x, c.y, r));
if (!Sim.Active)
{
var m = new GameObject("FogCleared");
m.transform.SetParent(transform, false);
m.transform.position = new Vector3(c.x, c.y, 0f);
m.transform.localScale = new Vector3(r * 2f, r * 2f, 1f);
m.AddComponent<SpriteMask>().sprite = ProceduralSprites.Circle;
fogMasks.Add(m);
Fx.Popup(c, "안개 걷힘", new Color(0.9f, 0.95f, 1f));
}
return true;
}
/// <summary>숨은 함정: origin → dir 로 dist 안에서 중심이 처음 함정에 드는 거리(+0.06 = 안으로 살짝) · 없으면 dist. 조준 미리보기(아군)는 모른다.</summary>
public float TrapStop(Unit actor, Vector2 origin, Vector2 dir, float dist)
{
@ -463,7 +446,7 @@ namespace TKS
bool any = false;
foreach (var g in items)
{
if (!g.ZoneActive || (g.Zone != GimmickZone.Slow && g.Zone != GimmickZone.High)) continue;
if (!g.ZoneActive || (g.Zone != GimmickZone.Slow && g.Zone != GimmickZone.High && g.Zone != GimmickZone.River)) continue;
if (Combat.SegmentPointDistance(origin, origin + dir * budget, g.Shape.Center) > g.Shape.BoundingRadius + 0.01f) continue;
any = true;
break;
@ -481,6 +464,7 @@ namespace TKS
if (!g.ZoneActive || !g.Shape.Contains(p)) continue;
if (g.Zone == GimmickZone.Slow) m *= Mathf.Max(1f, g.P(1, 1f));
else if (g.Zone == GimmickZone.High && !g.Shape.Contains(origin)) m *= Mathf.Max(1f, g.P(3, 1f));
else if (g.Zone == GimmickZone.River && !OnBridge(p)) m *= Mathf.Max(1f, g.P(3, 1f)); // 강 = 늪과 같은 판정(다리 구간 제외 · PD 2026-11-06)
}
float cost = step * m;
if (used + cost >= budget)
@ -523,17 +507,17 @@ namespace TKS
return 1f + add;
}
/// <summary>안개 안 유닛 = 사선 표적·화살 판정에서 빠진다(안에서 쏘기는 가능).</summary>
/// <summary>안개 안 유닛(걷힌 자리 제외) — 받는 원거리 피해 ×p1(PD 2026-11-06 개정 · 표적·사선은 정상).</summary>
public bool Fogged(Unit u)
{
return u != null && ZoneAt(u.Position, GimmickZone.Fog) != null;
return u != null && FogAt(u.Position) != null;
}
/// <summary>광역 중심이 안개면 범위 × p1.</summary>
public float FogSplashMult(Vector2 c)
/// <summary>원거리 피해(화살 · 책사 중심·광역) 배율 = 안개 안이면 p1.</summary>
public float RangedTakenMult(Unit d)
{
var g = ZoneAt(c, GimmickZone.Fog);
return g != null ? g.P(1, 1f) : 1f;
var f = d != null ? FogAt(d.Position) : null;
return f != null ? Mathf.Max(0f, f.P(1, 1f)) : 1f;
}
/// <summary>광역이 중심 → p 사이에서 막히는가(성문·성벽·바위 = 투사체 막힘 + 책사 제거 불가).</summary>
@ -554,7 +538,7 @@ namespace TKS
public float KnockMult(Unit e)
{
if (e == null) return 1f;
if (ZoneAt(e.Position, GimmickZone.Slow) != null) return 0f;
if (ZoneAt(e.Position, GimmickZone.Slow) != null || RiverAt(e.Position) != null) return 0f; // 늪·강 안 = 밀기 0
var ice = ZoneAt(e.Position, GimmickZone.Slide);
return ice != null ? Mathf.Max(0f, ice.P(2, 1f)) : 1f;
}
@ -848,12 +832,15 @@ namespace TKS
}
}
}
// 강(PD 2026-11-06 개정): 강가·다리에서 밀려(밀림·튕김·반작용·넉백) 강물에 들어가면 최대 HP p1 + p2 턴 행동 불가 · 행동당 1회 · 걸어 들어가면 느려지기만
var fell = new HashSet<Unit>();
foreach (var f in Forced)
{
var u = f.Unit;
if (u == null || !u.IsAlive || f.Intended - f.Actual < 0.05f || !OnBridge(u.Position)) continue;
var river = RiverAhead(u, f.Dir, f.Intended - f.Actual + 0.1f);
if (u == null || !u.IsAlive || fell.Contains(u) || RiverAt(f.From) != null) continue;
var river = RiverEntered(f.From, f.To);
if (river == null) continue;
fell.Add(u);
PctDamage(u, river.P(1, 0f), "강에 빠짐");
Stun(u, Mathf.Max(1, Mathf.RoundToInt(river.P(2, 1f))), "강에 빠짐");
o.Notes.Add(u.DisplayName + " 강에 빠짐");
@ -873,21 +860,24 @@ namespace TKS
return s.Contains(end);
}
GimmickInstance RiverAhead(Unit u, Vector2 dir, float dist)
/// <summary>선분 a→b 가 강물(다리 제외)에 들어가는 첫 강 · 없으면 null.</summary>
GimmickInstance RiverEntered(Vector2 a, Vector2 b)
{
Physics2D.SyncTransforms();
foreach (var h in Physics2D.CircleCastAll(u.Position, u.Radius * Combat.CastRadiusScale, dir, dist))
float len = Vector2.Distance(a, b);
if (len < 0.01f) return null;
int n = Mathf.Max(1, Mathf.CeilToInt(len / GimmickShape.SweepStep));
for (int i = 1; i <= n; i++)
{
var g = InstOf(h.collider);
if (g != null && g.Zone == GimmickZone.River && g.BlocksMove) return g;
var r = RiverAt(Vector2.Lerp(a, b, (float)i / n));
if (r != null) return r;
}
return null;
}
public void NoteForced(Unit u, Vector2 dir, float intended, float actual)
public void NoteForced(Unit u, Vector2 from, Vector2 to)
{
if (u == null || items.Count == 0 || dir.sqrMagnitude < 1e-6f) return;
Forced.Add(new ForcedMove { Unit = u, Dir = dir.normalized, Intended = intended, Actual = actual });
if (u == null || items.Count == 0 || (to - from).sqrMagnitude < 1e-6f) return;
Forced.Add(new ForcedMove { Unit = u, From = from, To = to });
}
// ---------- 가상 실행(적 AI) 스냅숏 ----------
@ -903,11 +893,12 @@ namespace TKS
public readonly List<Vector2> Center = new List<Vector2>();
public readonly Dictionary<Unit, GimmickUnitState> States = new Dictionary<Unit, GimmickUnitState>();
public GimmickInstance Template;
public int FogHoles;
}
public Snapshot Take()
{
var s = new Snapshot { Count = items.Count, Template = splashTemplate };
var s = new Snapshot { Count = items.Count, Template = splashTemplate, FogHoles = fogHoles.Count };
foreach (var g in items)
{
s.Refs.Add(g);
@ -945,6 +936,7 @@ namespace TKS
g.SetCollidersEnabled(g.BlocksMove);
}
splashTemplate = s.Template;
if (fogHoles.Count > s.FogHoles) fogHoles.RemoveRange(s.FogHoles, fogHoles.Count - s.FogHoles);
foreach (var kv in s.States)
{
GimmickUnitState cur;

View File

@ -51,6 +51,7 @@ namespace TKS
case GimmickZone.Slide: return new Color(0.55f, 0.9f, 1f, 0.75f);
case GimmickZone.High: return new Color(0.85f, 0.7f, 0.35f, 0.7f);
case GimmickZone.Fog: return new Color(0.95f, 0.95f, 1f, 0.6f);
case GimmickZone.River: return new Color(0.25f, 0.5f, 0.95f, 0.75f);
}
return new Color(1f, 1f, 1f, 0f);
}
@ -119,6 +120,7 @@ namespace TKS
{
if (n.Contains("미끄러짐")) events.Add("미끄러짐");
if (n.Contains("강에 빠짐")) events.Add("강에 빠짐");
if (n.Contains("안개 걷힘")) events.Add("안개 걷힘");
int k = n.IndexOf("자기 −");
if (k >= 0) events.Add(n.Substring(k)); // 튕김 자기 피해(bounce_pct · cav_self_pct)
}
@ -193,7 +195,7 @@ namespace TKS
int start = -1;
for (int k = 0; k <= n; k++)
{
bool inside = k < n + 1 && g.Shape.Contains(Vector2.Lerp(a, b, (float)k / n));
bool inside = Inside(g, Vector2.Lerp(a, b, (float)k / n));
if (inside && start < 0) start = k;
if ((!inside || k == n) && start >= 0)
{
@ -210,6 +212,15 @@ namespace TKS
}
}
/// <summary>띠를 그릴 자리: 강 = 다리 구간 제외 · 안개 = 걷힌 자리 제외.</summary>
bool Inside(GimmickInstance g, Vector2 p)
{
if (!g.Shape.Contains(p)) return false;
if (g.Zone == GimmickZone.River) return layer.RiverAt(p) != null;
if (g.Zone == GimmickZone.Fog) return layer.FogAt(p) != null;
return true;
}
// ---------- 그리기(풀) ----------
static Material DashMaterial

View File

@ -109,6 +109,7 @@ namespace TKS
sr.transform.localRotation = Quaternion.Euler(0f, 0f, s.Type == GimmickShapeType.Rect ? s.Rot : 0f);
fill = sr;
if (art != null) baseColor = Color.white;
if (g.Zone == GimmickZone.Fog) sr.maskInteraction = SpriteMaskInteraction.VisibleOutsideMask; // 책사 광역이 걷은 자리(SpriteMask)는 안 그림
}
float h = Mathf.Clamp(Mathf.Min(s.Type == GimmickShapeType.Rect ? Mathf.Min(s.Size.x, s.Size.y) : s.BoundingRadius) * 0.55f, 0.22f, 0.5f);

View File

@ -321,22 +321,45 @@ namespace TKS.Tests
}
[UnityTest]
public IEnumerator Fog_TargetExcluded_SplashCenterInFogShrinks()
public IEnumerator Fog_RangedDamageXp1_TargetableNormally_StrategistSplashClearsFog()
{
var d = Def("fog");
yield return Fresh();
L.Spawn("fog", GimmickShape.Circle(new Vector2(0f, 1.5f), 1.5f));
L.Spawn("fog", GimmickShape.Circle(new Vector2(0f, 2f), 3.5f));
var archer = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -3f), "궁병");
var hidden = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1.5f), "안개 속 적");
var far = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 4.6f), "안개 깊은 적");
hidden.MaxHp = hidden.Hp = far.MaxHp = far.Hp = 5000;
Assert.AreEqual(d.p1, GimmickLayer.RangedTakenMultOf(hidden), 1e-4f, "안개 안 = 받는 원거리 피해 ×p1(PD 2026-11-06 개정)");
Assert.AreEqual(1f, GimmickLayer.RangedTakenMultOf(archer), 1e-4f);
var plan = bm.Resolver.PreviewShot(archer, hidden.Position, false);
Assert.IsNotNull(plan);
Assert.IsNull(plan.Target, "안개 안 유닛 = 사선 표적 제외");
Assert.AreEqual(hidden, plan.Target, "표적·사선은 정상");
Sim.Active = true; // 분산 0 으로 같은 공식 비교
int inFog = Combat.Damage(cfg, archer, hidden, false, bm.Rng, false);
int melee = Combat.Damage(cfg, archer, hidden, false, bm.Rng, true);
Vector2 keep = hidden.Position;
hidden.Position = new Vector2(6f, -6f);
int open = Combat.Damage(cfg, archer, hidden, false, bm.Rng, false);
int meleeOpen = Combat.Damage(cfg, archer, hidden, false, bm.Rng, true);
hidden.Position = keep;
Sim.Active = false;
Assert.AreEqual(open * d.p1, inFog, 1.01f, "원거리 피해 ×p1");
Assert.AreEqual(meleeOpen, melee, "근접은 그대로");
var o = new ActionOutcome();
yield return Act(archer, hidden.Position, o);
Assert.IsFalse(o.Fired, "쏘지 않고 이동");
Assert.AreEqual(hidden.MaxHp, hidden.Hp);
Assert.AreEqual(d.p1, L.FogSplashMult(new Vector2(0f, 1.5f)), 1e-4f, "광역 중심이 안개 = 범위 ×p1");
Assert.AreEqual(1f, L.FogSplashMult(new Vector2(5f, -5f)), 1e-4f);
Assert.AreEqual(hidden, o.ShotHit, "안개 속 적도 화살에 맞는다");
Assert.Less(hidden.Hp, hidden.MaxHp);
var st = bm.SpawnUnit(UnitType.Strategist, Faction.Player, new Vector2(1.5f, -3f), "책사");
var os = new ActionOutcome();
yield return Act(st, hidden.Position, os);
Assert.IsTrue(os.Fired);
Assert.AreEqual(1, L.FogHoleCount, "책사 광역 = 그 반경만큼 안개 걷힘");
Assert.IsFalse(L.Fogged(hidden), "광역 반경 안 = 안개 효과 없음");
Assert.AreEqual(1f, GimmickLayer.RangedTakenMultOf(hidden), 1e-4f);
Assert.IsTrue(L.Fogged(far), "반경 밖 안개는 그대로");
}
[UnityTest]
@ -388,15 +411,25 @@ namespace TKS.Tests
}
[UnityTest]
public IEnumerator RiverBridge_BlocksMove_ArrowPasses_KnockedOffBridgeFalls()
public IEnumerator River_LikeSwamp_WalkInOut_PushedInFromBankOrBridgeFalls_ArrowPasses()
{
var river = Def("river");
yield return Fresh();
L.Spawn("river", GimmickShape.Box(new Vector2(0f, 3f), new Vector2(20f, 2f)));
L.Spawn("bridge", GimmickShape.Box(new Vector2(0f, 3f), new Vector2(1.2f, 2f)));
// 걸어 들어가기 = 늪처럼 강 안 구간 이동량 ×p3 · 빠짐 없음 · 다시 걸어 나올 수 있음(PD 2026-11-06 개정)
var walker = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(-5f, 0.5f), "보병");
yield return Act(walker, new Vector2(-5f, 4.8f), new ActionOutcome(), true);
Assert.AreEqual(Contact(walker, 2f), walker.Position.y, 0.08f, "강 = 통과 불가");
Vector2 reach = bm.Resolver.ReachPoint(walker, new Vector2(-5f, 4.5f));
Assert.AreEqual(2f + (4f - 1.5f) / river.p3, reach.y, 0.1f, "강 안 구간 ×p3");
yield return Act(walker, new Vector2(-5f, 4.5f), new ActionOutcome(), true);
Assert.AreEqual(reach.y, walker.Position.y, 0.08f);
var ws = L.StateOf(walker);
Assert.IsTrue(ws == null || ws.Stun == 0, "걸어 들어가면 빠짐 없음");
yield return Act(walker, walker.Position + new Vector2(0f, 4.5f), new ActionOutcome(), true);
Assert.Greater(walker.Position.y, 4.5f, "강에서 걸어 나온다");
var onFoot = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, 0.5f), "다리 보병");
Assert.AreEqual(4.5f, bm.Resolver.ReachPoint(onFoot, new Vector2(0f, 4.5f)).y, 0.08f, "다리 위 = 강 효과 없음");
var archer = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(-7f, 0.5f), "궁병");
var across = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(-7f, 4.5f), "건너편 적");
@ -404,16 +437,26 @@ namespace TKS.Tests
yield return Act(archer, across.Position, oa);
Assert.AreEqual(across, oa.ShotHit, "투사체는 강 통과");
var cav = bm.SpawnUnit(UnitType.Cavalry, Faction.Player, new Vector2(0f, 0f), "기병");
var onBridge = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0.05f, 3f), "다리 위 적");
// 강가에서 밀려 강물에 = 빠짐
var pusher = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(6f, -1.5f), "밀치는 보병");
var bank = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(6f, 1f), "강가 적");
bank.MaxHp = bank.Hp = 5000;
var ob = new ActionOutcome();
yield return Act(pusher, new Vector2(6f, 3f), ob);
Assert.Greater(bank.Position.y, 2f, "강물로 밀려 들어감");
Assert.AreEqual(Mathf.RoundToInt(river.p2), L.StateOf(bank).Stun, "강가에서 밀려도 빠짐 = p2 턴 행동 불가");
Assert.IsTrue(ob.Notes.Exists(n => n.Contains("강에 빠짐")));
Assert.AreEqual(0f, L.KnockMult(bank), 1e-4f, "강 안 = 기병 넉백 0(늪과 같음)");
// 다리에서 옆으로 밀려 강물에 = 빠짐
var cav = bm.SpawnUnit(UnitType.Cavalry, Faction.Player, new Vector2(0f, -2f), "기병");
var onBridge = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0.4f, 3f), "다리 위 적");
onBridge.MaxHp = onBridge.Hp = 5000;
onFoot.Position = new Vector2(-8f, -8f);
var o = new ActionOutcome();
yield return Act(cav, new Vector2(0f, 5.6f), o);
Assert.Contains(onBridge, o.Attacked, "다리 = 통행 가능(관통)");
var s = L.StateOf(onBridge);
Assert.IsNotNull(s);
Assert.AreEqual(Mathf.RoundToInt(river.p2), s.Stun, "다리에서 옆으로 밀려 강 = p2 턴 행동 불가");
Assert.IsTrue(o.Notes.Exists(n => n.Contains("강에 빠짐")));
yield return Act(cav, new Vector2(0f, 4.4f), o);
Assert.Contains(onBridge, o.Attacked, "다리 = 통행(관통)");
Assert.AreEqual(Mathf.RoundToInt(river.p2), L.StateOf(onBridge).Stun, "다리에서 옆으로 밀려 강 = 빠짐");
yield return L.TurnStart(Faction.Enemy);
Assert.IsTrue(GimmickLayer.Skips(onBridge), "그 진영 턴을 쉰다");
}
@ -504,7 +547,8 @@ namespace TKS.Tests
if (id == "9-11")
{
var river = items.Find(g => g.Zone == GimmickZone.River);
Assert.AreEqual(2, river.Colliders.Count, "강 = 다리 구간을 뺀 2 조각");
Assert.AreEqual(0, river.Colliders.Count, "강 = 늪처럼 통과 가능한 지대(콜라이더 없음 · PD 2026-11-06 개정)");
Assert.IsTrue(layer.OnBridge(new Vector2(0f, 3f)) && layer.RiverAt(new Vector2(0f, 3f)) == null, "다리 구간 = 강 효과 없음");
}
foreach (var u in bm.Units)
{

View File

@ -48,7 +48,7 @@ Unity.exe -projectPath <proj> -executeMethod TKS.EditorTools.ProjectSetup.AutoPl
- **스킬(2026-11-05 #1093 B)** — SOT = `Assets/Data/OutGame/skills.csv`(effect_key `;` 복수 · p1~p3 · lv_scale · needs_impl) + `skill_levels.csv` → 임포트 자산 `SkillInfoDb`(effect 열 포함 · 표시 + 전투 공용). 처리기 = `Runtime/Skills/`: `SkillKeys`(51종 · 레벨 계수 방식 · 팀 키 · 표에 없는 키 = 임포트 오류) · `SkillSystem`(유닛별 쿨·예약 · 즉시형 시한 효과 · 행동 훅 · 호위 피해 분담 · HUD 한 줄) · `SkillMods`(전투 코드가 수치 질의) · `SkillRules`(상한 = 등급 + ★3 · ★5 패시브 배율 = evolve.csv skill_unlock · 레벨업 · 스킬서 판매). 전투 코드에 스킬 수치를 직접 쓰지 않고 `SkillMods` 로 읽는다. 테스트 = `Tests/PlayMode/SkillTests`. 캡처 = `-executeMethod TKS.EditorTools.DataImporter.CaptureSkills -skillDir <dir>`.
- **아웃게임 2차(2026-11-05~06 #1093 C)** — 표 10장 추가(`training` · `training_ranks` · `patrol` · `dev_tree` · `treasures` · `treasure_sets` · `chests`(+ iron/buy 열) · `tower` · `dungeons`(+ reward_amount) · `arena`(+ 상점 키)) + `economy.csv` C 키 24 → 임포트 자산 18(A 9 + C 9 · `OutGameTables.C.cs` 검증). 코드 = `OutGame/Domestic/`(`TrainingRules` 훈련 · `PatrolRules` 순찰 · `DevRules` 개발 효과 키 → 적용 지점 · `DomesticScreen`) · `OutGame/Treasure/`(`TreasureRules` 보유·세트·강화·조건 카운터·상자 · `TreasureScreen`) · `OutGame/Arena/`(`ArenaRules` 탑·던전·투기장·적장 · `ArenaScreen`). 스탯 = §3-2 × (1 + 훈련(병종·항목)) × (1 + 보물(항목)) = `HeroRules.Compute(…, save)` · 보물 crit/range/splash = `BattleManager.SideEffectsProvider` → `SkillMods.Get` 진영 효과(`side_crit_pct` · `range_mult` · `splash_radius_mult`) · 조건 효과 카운터 = `Runtime/BattleEvents.Raise`(전투 코드 1줄씩 · 아군만) · 연무장 전투 = `BattleManager.StartMode(BattleMode)`(적 편성 · 제한 턴 · 결과 탭 = `onExit`) → `OutGameSession.OnModeEnd`. 테스트 = `Tests/PlayMode/OutGameCTests`. 캡처 = `-executeMethod TKS.EditorTools.DataImporter.CaptureC -cDir <dir>`(사본 · 10장).
- **영웅 56명(PD #1096 · 2026-11-06)** — heroes 71행(영웅 56 + 병사 15) · skills 112 · hero_gear 1344 · 신규 effect_key 4(`team_dmg_pct_turn` · `cooldown_add` · `charm` · `heal_self_pct`) + 기존 키 조건 열(받는 피해 p2 = HP 임계/2 보스/3 근접 · p3 = 출전 인원 아군 전원 · 최대 HP·반격 아군 전원 · 피해 % 발동 턴) = `SkillMods`·`SkillSystem` · 대장군 `pushed_ally_attack_on_push` 는 표에서 미사용(처리기 유지).
- **지형 기믹 레이어(PD #1097 · 2026-11-06 세션 D)** — SOT = `Assets/Data/Gimmicks/{gimmicks,stage_gimmicks,tutorial_tips}.csv`(기획 `tables/` 사본 · utf-8-sig) → 메뉴 `TKS/Import Gimmicks (기믹 임포트)`(`Editor/GimmickImporter` · 오류 시 자산 불변 · `ai_weights.csv` 도 함께) → `Resources/Gimmicks/GimmickDb`·`StageGimmickTable` · `Resources/AI/AiWeights`. 전투 시작 = `GimmickLayer.Build(stage)`(장-번호 배치 · 배경 위 · 유닛 아래 · 장애물 = 콜라이더 + `GimmickBody` · 지대 = 모양 스윕 · 강 콜라이더 = 다리 구간을 뺀 조각 · 겹친 유닛은 빈 자리로). 전투 코드는 질의만: `Combat.FreeTravel`(장애물 = 벽 · 숨은 함정 정지) · `FirstUnitOnArrow`(pass/block/destroy · 안개 제외) · `Combat.Damage`(군기 공 · 고지 근접 피격) · `SkillMods.Range`(고지 사거리) · `BattleManager` 턴 시작 `TurnStart`(군기·중독·불·행동 불가). 충돌 반응·폭발·굴림·미끄러짐·지대 = `Gimmicks/ActionResolver.Gimmicks.cs`(partial). 배치 도구 = `TKS/Gimmick Painter (기믹 배치)`. 테스트 = `Tests/PlayMode/GimmickTests` · 캡처 = `-executeMethod TKS.EditorTools.GimmickImporter.Capture -gimmickDir <dir>`(사본).
- **지형 기믹 레이어(PD #1097 · 2026-11-06 세션 D)** — SOT = `Assets/Data/Gimmicks/{gimmicks,stage_gimmicks,tutorial_tips}.csv`(기획 `tables/` 사본 · utf-8-sig) → 메뉴 `TKS/Import Gimmicks (기믹 임포트)`(`Editor/GimmickImporter` · 오류 시 자산 불변 · `ai_weights.csv` 도 함께) → `Resources/Gimmicks/GimmickDb`·`StageGimmickTable` · `Resources/AI/AiWeights`. 전투 시작 = `GimmickLayer.Build(stage)`(장-번호 배치 · 배경 위 · 유닛 아래 · 장애물 = 콜라이더 + `GimmickBody` · 지대 = 모양 스윕 · 강 = 늪처럼 통과하는 지대(강 안 ×p3 · 다리 구간 = 효과 없음 · 강가·다리에서 밀려 강물에 들면 빠짐 · PD 2026-11-06 개정) · 겹친 유닛은 빈 자리로). 전투 코드는 질의만: `Combat.FreeTravel`(장애물 = 벽 · 숨은 함정 정지) · `FirstUnitOnArrow`(pass/block/destroy) · `Combat.Damage`(군기 공 · 고지 근접 피격 · 안개 원거리 피격 ×p1 · 책사 광역이 그 반경 안개를 걷음) · `SkillMods.Range`(고지 사거리) · `BattleManager` 턴 시작 `TurnStart`(군기·중독·불·행동 불가). 충돌 반응·폭발·굴림·미끄러짐·지대 = `Gimmicks/ActionResolver.Gimmicks.cs`(partial). 배치 도구 = `TKS/Gimmick Painter (기믹 배치)`. 테스트 = `Tests/PlayMode/GimmickTests` · 캡처 = `-executeMethod TKS.EditorTools.GimmickImporter.Capture -gimmickDir <dir>`(사본).
- **병종 규칙 개정 + 예측 AI(PD #1098 · 2026-11-06)** — 보병 = 적과 싸운 뒤 튕겨나는 길의 다른 적과 연쇄 교환 1회(`ActionOutcome.Chains`) · 창병 = 상대만 밀리고 자신은 × `spearRecoilRatio` 반대로(밀며 전진 폐기). 적 턴 = `Runtime/AI/TacticalAI`(후보 방위 × 파워 3 × 액티브 → `Sim` 가상 실행(연출 없음 · 분산 0 · 상태 복원) → `ai_weights.csv` 점수 · 서포터 → 돌격 → 사격 · 1기마다 재평가) · 가중치 자산이 없으면 `EnemyAI.Decide`(기존 조준) · 적 액티브 = 1부 보스만(`boss_active`). 가상 실행 중(`Sim.Active`)에는 `Fx`·`UnitView`·`BattleEvents`·HUD 갱신이 꺼진다 — 새 연출 코드도 `Sim.Active` 면 건너뛸 것. 조준 미리보기도 같은 가상 실행(`GimmickPreview` · 숨은 함정 모름). 테스트 = `Tests/PlayMode/TacticalAITests`.
- 아웃게임 코드 = `Assets/Scripts/OutGame/**`(asmref → TKS.Runtime) — `OutGameSession`(표 + 세이브 · 바꾸는 동작은 즉시 저장) · 규칙 `HeroRules`·`EconomyRules`·`GachaRules` · 화면 `UI/`(로비·영웅·편성·거래·결과 패널 · `OutGameRoot` 가 전환). 세이브 = `persistentDataPath/save.json`(테스트·검증 캡처는 `SaveStore.UseMemory` / SessionState `TKS_SAVE_MEMORY` 로 메모리 전용). 출전 = `BattleManager.LineupProvider`(편성 프리셋 → `playerSpawnPoints` 자리 0~2 전열 · 3~4 후열). 화면 캡처 = `Unity.exe -projectPath <사본> -executeMethod TKS.EditorTools.DataImporter.CaptureOutGame -outgameDir <dir>`.
- **챕터·스테이지 SOT = `Assets/Data/Stages/*.csv`(utf-8-sig · 2026-11-05 145 스테이지 · 기획서 #1086 §4 전사)** — `stages.csv`(chapter·stage·title·type normal|elite|boss·place·faction·병종 4 수·generals `id:병종:true|false`·bg_theme·note) · `chapters.csv`(장 제목·연도·**월드맵 맵 id**·AI 출발값) · `generals.csv`(무장 id → 이름·배율 덮어쓰기) · `factions.csv`(세력 코드 → 이름·병사 아트 접두). 고친 뒤 메뉴 `TKS/Import Stages (스테이지 임포트)`(`StageImporter` · 형식 오류면 자산 미변경) → `BalanceConfig.chapters·stages`. 적 좌표 = `BalanceConfig.formation` 규칙(전위·중군·후군·장수 줄 · `StageTable.Enemies`) · 장수 배율 = `generals.csv` 가 비면 `boss/eliteGeneral*Mult`. 월드맵 노드 = 스테이지 표(챕터별 수 · 보스 = type boss) · 노드 자리 = `ChapterMap/Data/<맵>.json` `slots_N`(없으면 길 위 균등).