From 9f815df4ca1fe25a388ef58c7ec148a4b4c924b3 Mon Sep 17 00:00:00 2001 From: AcideFluorhydrique <139669368+AcideFluorhydrique@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:59:17 +0800 Subject: [PATCH] Fight with the reference damage formula and absolute HP Each attack rolls between a kind's minimum and maximum attack, and each point of defence adds 1.6% to the divisor. HP is absolute and grows with formation and level; below half HP a unit hits at half strength; the counter is full strength and uses the defender's HP after the hit. Terrain no longer shields the defender but hampers tanks and guns attacking into it; city walls take the roll undefended. Supply, the counter table, entrenchment, commanders and the embarked penalty stay as our own rules on top. Save format goes to version 3. --- README.md | 4 +- README.zh.md | 4 +- .../mapconquer/ai/AiPlayer.kt | 46 +- .../mapconquer/game/AirOps.kt | 79 ++- .../mapconquer/game/Orders.kt | 34 +- .../mapconquer/game/SaveGame.kt | 13 +- .../mapconquer/game/Session.kt | 21 +- .../mapconquer/render/HudRenderer.kt | 18 +- .../mapconquer/render/MapRenderer.kt | 4 +- .../mapconquer/render/PanelRenderer.kt | 15 +- .../mapconquer/units/ArmyUnit.kt | 82 ++- .../mapconquer/units/Combat.kt | 488 ++++++++++-------- .../mapconquer/units/UnitKind.kt | 70 ++- .../mapconquer/world/Province.kt | 22 +- .../mapconquer/world/Terrain.kt | 47 +- app/src/main/res/values-b+zh+Hans/strings.xml | 8 +- app/src/main/res/values-b+zh+Hant/strings.xml | 8 +- app/src/main/res/values/strings.xml | 8 +- .../mapconquer/game/ConquestSmokeTest.kt | 2 +- .../mapconquer/game/SessionTest.kt | 15 +- .../mapconquer/units/CombatTest.kt | 182 +++++-- docs/original-behavior.md | 105 +++- tools/genstrings.py | 47 +- 23 files changed, 838 insertions(+), 484 deletions(-) diff --git a/README.md b/README.md index dd52d6f..345b83d 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,11 @@ Traditional Chinese, Simplified Chinese and English, switchable inside the app. | System | Summary | | --- | --- | | Map | Pointy-top hexes, axial maths, odd-r storage. The world map is 120×76 and wraps east–west. It is deliberately stretched — Europe and East Asia wide, the oceans narrow — but the geography stays true: which countries share a border and which are separated by sea is checked every time the map is generated. | -| Terrain | 13 types, each with a movement cost, a defence bonus and an income. Vehicles cannot enter mountains, jungle or swamp. | +| Terrain | 13 types, each with a movement cost, an income, and a penalty on tanks and guns attacking into it. Vehicles cannot enter mountains, jungle or swamp. | | Territory | Ownership is per **province**, not per hex; each province is one city and its land. A city falls when its defence is worn down and a land unit stands in it, and the whole province changes hands with it. Hong Kong, Gibraltar and Belfast are single-hex strongholds. | | Units | 17 kinds: 11 land, 6 sea. Each has four attack values — against infantry, armour, ships and aircraft — so the counter matters more than the raw number. Units are built as formations of one to four. | | Air power | Not units on the board but missions you pay for: fighter strikes, bomber strikes (which can also hit an empty city's walls) and airdrops, flown from cities and carriers (bombers need a large city). Anti-air, cruisers and destroyers blunt strikes near them. | -| Combat | Ratio-based (`55·A/(A+D)`), bounded by construction, ±8% deterministic variance. Artillery fires from range and takes no return fire; it is nearly helpless in melee. A unit inside a city takes half the damage, and the city's walls take a further 70% of the blow. | +| Combat | Each attack rolls between the unit's minimum and maximum attack; each point of defence adds 1.6% to the divisor (`dmg = atk·1000/(def·16+1000)`). HP is absolute and grows with formation and level; below half HP a unit hits at half strength. Survivors always return fire at full strength. Artillery fires from range and takes no return fire, and cannot fire back in melee. A unit inside a city with walls takes half the damage, and the walls take their own share. Rules follow `docs/original-behavior.md`. | | Zones of control | Moving next to an enemy ends your move. Armour ignores this once per move, which is what makes it the tool for opening a breach. | | Supply | Spreads from your own cities along your own territory, priced in movement cost — so mountains break a supply line faster than plains do. Out of supply means losing strength, then dying. Supply trucks and headquarters carry a small supply bubble with them, even into enemy land. Ships carry their own stores: away from port they weaken to four fifths of their strength but never starve. | | Experience | Units level 1→5 from damage dealt and kills. | diff --git a/README.zh.md b/README.zh.md index 0706701..9624b17 100644 --- a/README.zh.md +++ b/README.zh.md @@ -26,11 +26,11 @@ Android 上的六角格回合制大戰略遊戲。自由軟體,沒有廣告、 | 系統 | 說明 | | --- | --- | | 地圖 | 尖頂六角格,軸向座標做數學、奇數列偏移做儲存。世界地圖 120×76,東西環繞。地圖刻意變形 —— 歐洲與東亞放大、大洋壓扁 —— 但地理關係是真的:哪些國家接壤、哪些之間隔著海,每次產生地圖時都會檢查。 | -| 地形 | 13 種,各有移動成本、防禦加成與產值。輪車與履帶進不了山地、叢林與沼澤。 | +| 地形 | 13 種,各有移動成本、產值,以及對打進來的戰車與火炮的傷害懲罰。輪車與履帶進不了山地、叢林與沼澤。 | | 領土 | 歸屬算在**省**上而不是格上,一省就是一座城與它的土地。城防被打光、陸軍站進城裡,這座城就陷落,整省跟著易主。香港、直布羅陀、貝爾法斯特是只佔一格的據點。 | | 兵種 | 17 種:陸軍 11、海軍 6。每種都有四個獨立的攻擊值 —— 對步兵、對裝甲、對艦艇、對空 —— 所以「派誰去」比「數字多大」更重要。部隊建造時就決定編制,一到四個。 | | 空軍 | 不是棋盤上的棋子,而是花錢買的任務:戰鬥機打擊、轟炸機打擊(也能轟炸無人城市的城防)與空降,從城市與航艦起飛(轟炸機要大城市)。附近的防空、巡洋艦、驅逐艦會削弱打擊。 | -| 戰鬥 | 攻防比公式(`55·A/(A+D)`),天然有界,加上 ±8% 的決定性亂數。火炮遠射不被反擊,但被貼身幾乎無法自衛。城裡的部隊只受一半傷害,城防另外承受這一擊的 70%。 | +| 戰鬥 | 每次攻擊在最小與最大攻擊之間擲骰;每 1 點防禦讓分母多 1.6%(`傷害 = 攻·1000/(防·16+1000)`)。HP 是絕對值,隨編制與等級增加;不到半血攻擊減半。活下來的守方一定全力反擊。火炮遠射不被反擊,被貼身也還不了手。城防還在時城裡的部隊只受一半傷害,城防另外承受一份。規則見 `docs/original-behavior.md`。 | | 控制區 | 走到敵人旁邊就必須停下。裝甲部隊每次移動可以無視一次,這正是它作為突破口的價值。 | | 補給 | 從自己的城市沿自己的領土擴散,用移動成本計價 —— 所以越過山脈的攻勢會比沿平原推進更快斷補。斷補給會先掉戰力,最後餓死。補給車與司令部自帶一個小型補給圈,在敵境一樣有效。軍艦自帶物資:離港會變弱到八成戰力,但不會餓死。 | | 經驗 | 部隊靠打出的傷害與擊殺從 1 級升到 5 級。 | diff --git a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/ai/AiPlayer.kt b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/ai/AiPlayer.kt index 434dce8..246fc6f 100644 --- a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/ai/AiPlayer.kt +++ b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/ai/AiPlayer.kt @@ -146,9 +146,9 @@ class AiPlayer(private val session: Session, private val nationId: Int) { AirOps.collectTargets(session, nationId, mission, airTargets) for (tile in airTargets) { val defender = AirOps.strikeTarget(session, nationId, mission, tile) ?: continue - val share = defender.scaledDamage(AirOps.previewDamage(session, nationId, mission, defender)) - var value = share * defender.kind.cost * defender.size / 100f - if (share >= defender.hp) value += defender.kind.cost * defender.size * 0.5f + val dealt = AirOps.previewDamage(session, nationId, mission, defender) + var value = worth(defender, dealt) + if (dealt >= defender.hp) value += defender.kind.cost * defender.size * 0.5f val ratio = value / mission.totalCost if (ratio > bestRatio) { bestRatio = ratio @@ -290,7 +290,13 @@ class AiPlayer(private val session: Session, private val nationId: Int) { // 因為拆城防不會直接減少對方的戰力,只是為佔領開路。 val pid = Orders.cityTargetAt(session, unit, tile) if (pid < 0) continue - val score = session.cityHp[pid].coerceAtMost(30) * 0.4f + val wall = Combat.previewCityStrike( + unit, + nation.techBonus(unit.kind.branch), + session.commandAura(unit), + session.moraleOf(unit) + ) + val score = wall.coerceAtMost(session.cityHp[pid]).coerceAtMost(CITY_STRIKE_SCORE_CAP) * 0.4f if (score > bestScore) { bestScore = score bestTile = tile @@ -299,20 +305,15 @@ class AiPlayer(private val session: Session, private val nationId: Int) { } val ctx = Orders.buildContext(session, unit, defender) val dealt = Combat.previewDamage(ctx) - val taken = if (Combat.canRetaliate(ctx)) { - Orders.previewDamage(session, defender, unit) * 6 / 10 - } else 0 + // 反擊跟主動攻擊同一條公式,而且看的是挨打之後的殘血。 + val taken = Combat.previewRetaliation(ctx, dealt) // 價值換算成錢:打掉一支貴的部隊比打掉一支便宜的值錢, // 而自己的損失也用同一把尺量,AI 才不會拿戰列艦去換運輸船。 - // 預測傷害是原始值,要先換算成對方實際會掉的成數再比。一個四編制的 - // 部隊值四支的錢,也要四支的傷害才打得死。 - val dealtShare = defender.scaledDamage(dealt) - val takenShare = unit.scaledDamage(taken) - var score = dealtShare * defender.kind.cost * defender.size / 100f - - takenShare * unit.kind.cost * unit.size / 100f - if (dealtShare >= defender.hp) score += defender.kind.cost * defender.size * 0.5f - if (takenShare >= unit.hp) score -= unit.kind.cost * unit.size * 0.8f + // 一個四編制的部隊值四支的錢,也要夠多的傷害才打得死。 + var score = worth(defender, dealt) - worth(unit, taken) + if (dealt >= defender.hp) score += defender.kind.cost * defender.size * 0.5f + if (taken >= unit.hp) score -= unit.kind.cost * unit.size * 0.8f // 守著城市的敵人優先清掉,那是勝利條件所在。 if (session.map.provinceAt(defender.tile)?.capitalTile == defender.tile) score *= 1.3f @@ -331,6 +332,13 @@ class AiPlayer(private val session: Session, private val nationId: Int) { * 目標選擇。順序就是優先級: * 先救自己快掉的城,再打對面最近的城,都沒有的話往最近的敵人靠。 */ + /** 打掉 [damage] 點 HP 值多少錢:依打掉的比例換算成這支部隊的造價。 */ + private fun worth(unit: ArmyUnit, damage: Int): Float { + if (unit.maxHp <= 0) return 0f + val share = damage.coerceAtMost(unit.hp).toFloat() / unit.maxHp + return share * unit.kind.cost * unit.size + } + private fun chooseGoal(unit: ArmyUnit): Int { if (profile == AiProfile.TURTLE) { threatenedOwnCity(unit)?.let { return it } @@ -411,8 +419,9 @@ class AiPlayer(private val session: Session, private val nationId: Int) { for (tile in reachable) { var score = (currentDistance - map.distance(tile, goal)) * 10f - // 同樣的推進距離下,挑防禦地形好的那一格。 - score += map.terrainAt(tile).defenceBonus * 0.25f + // 同樣的推進距離下,挑戰車與火炮難打的地形。 + val terrain = map.terrainAt(tile) + score += (terrain.armourPenalty + terrain.artilleryPenalty) * 0.25f // 別走出補給範圍。軍艦自帶物資,離港只是慢慢變弱,罰得輕得多。 if (!session.isSupplied(tile)) { score -= if (unit.kind.domain == Domain.SEA) 3f else 12f @@ -447,6 +456,9 @@ class AiPlayer(private val session: Session, private val nationId: Int) { const val AIR_RESERVE = 250 const val MAX_SORTIES_PER_TURN = 3 + /** 拆城防的分數上限(以一擊打掉的城防點數計),別讓 AI 光顧著拆牆。 */ + const val CITY_STRIKE_SCORE_CAP = 30 + /** 預期戰果至少要值任務價錢的這個比例才飛。 */ const val MIN_SORTIE_VALUE_RATIO = 0.75f const val MAX_GOAL_DISTANCE = 28 diff --git a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/game/AirOps.kt b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/game/AirOps.kt index 2a67fc3..c8153bd 100644 --- a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/game/AirOps.kt +++ b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/game/AirOps.kt @@ -18,7 +18,8 @@ import io.github.acidefluorhydrique.mapconquer.units.UnitKind * 地移 —— 全是這個尺度用不著的細節。現在空軍是「花錢買一次出擊」: * 從機場或航艦起飛,打一下(或放一支傘兵下去),然後就回去了。 * - * [strike] 的三個值依序是對 SOFT / ARMOURED / SHIP 的火力: + * 攻擊擲骰取參考遊戲戰鬥機 24–32、轟炸機 26–40 的量級。 + * [versus] 的三個值依序是對 SOFT / ARMOURED / SHIP 的效果百分比(本專案的規則): * 戰鬥機掃射步兵、轟炸機專打戰車與軍艦,兩者互補,沒有哪個全能。 */ enum class AirMission( @@ -28,13 +29,15 @@ enum class AirMission( val industry: Int, /** 離起飛點的最大距離(格)。 */ val range: Int, - val strike: IntArray + val attackMin: Int, + val attackMax: Int, + val versus: IntArray ) { - FIGHTER("mission_fighter", 70, 2, 6, intArrayOf(46, 12, 10)), - BOMBER("mission_bomber", 110, 3, 8, intArrayOf(16, 48, 52)), + FIGHTER("mission_fighter", 70, 2, 6, 24, 32, intArrayOf(100, 30, 25)), + BOMBER("mission_bomber", 110, 3, 8, 26, 40, intArrayOf(40, 100, 100)), /** 空降:在目標格放下一支新的步兵。價錢另加一支步兵。 */ - AIRDROP("mission_airdrop", 60, 2, 6, intArrayOf(0, 0, 0)); + AIRDROP("mission_airdrop", 60, 2, 6, 0, 0, intArrayOf(0, 0, 0)); val descKey: String get() = key + "_desc" @@ -43,8 +46,11 @@ enum class AirMission( /** 這個任務實際要付的錢。 */ val totalCost: Int get() = if (this == AIRDROP) cost + AirOps.PARATROOPER.cost else cost - fun strikeAgainst(target: TargetClass): Int = - if (target.ordinal < strike.size) strike[target.ordinal] else 0 + fun versus(target: TargetClass): Int = + if (attackMax > 0 && target.ordinal < versus.size) versus[target.ordinal] else 0 + + /** 打城牆:跟地面部隊一樣取對步兵、對裝甲兩欄的較高者。 */ + val versusCity: Int get() = maxOf(versus(TargetClass.SOFT), versus(TargetClass.ARMOURED)) companion object { val ALL: Array = values() @@ -79,7 +85,7 @@ object AirOps { /** 附近有空戰專家指揮官時的加成,半徑與司令部光環相同。 */ private const val AIR_EXPERT_RANGE = 3 - /** 空降部隊落地時,每一點防空火力扣掉的血(百分比),上限見下。 */ + /** 空降部隊落地時,每兩點防空火力扣掉 1% 的血,上限見下。 */ private const val DROP_FLAK_LOSS_DIVISOR = 2 private const val DROP_FLAK_MAX_LOSS = 60 @@ -131,7 +137,7 @@ object AirOps { if (!mission.isStrike) return null val unit = session.primaryUnitAt(tile) ?: return null if (!unit.isAlive || !session.isHostile(unit.nationId, nationId)) return null - if (mission.strikeAgainst(unit.kind.targetClass) <= 0) return null + if (mission.versus(unit.kind.targetClass) <= 0) return null // 看不見的潛艇炸不到 —— 跟地面部隊的規則一致。 if (nationId == session.playerNationId && !session.isUnitVisibleToPlayer(unit)) return null return unit @@ -219,8 +225,8 @@ object AirOps { /** * 目標四周敵方防空的總火力。 * - * 用兵種表「對空」那一欄:防空炮最高,巡洋艦、驅逐艦其次。殘血的防空 - * 打得比較少,所以先把防空炮打殘,再派飛機過去,是划算的順序。 + * 用兵種表的平均攻擊 × 「對空」那一欄:防空炮最高,巡洋艦、驅逐艦其次。 + * 殘血的防空打得比較少,所以先把防空炮打殘,再派飛機過去,是划算的順序。 */ fun flakAt(session: Session, nationId: Int, tile: Int): Int { var total = 0 @@ -230,17 +236,14 @@ object AirOps { val aa = unit.kind.attackAgainst(TargetClass.AIRCRAFT) if (aa <= 0) continue if (session.map.distance(unit.tile, tile) > FLAK_RANGE) continue - total += aa * ArmyUnit.attackPercent(unit.size) / 100 * unit.hp / ArmyUnit.MAX_HP + total += aa * ArmyUnit.attackPercent(unit.size) / 100 * unit.hp / unit.maxHp.coerceAtLeast(1) } return total } - /** 一次打擊的火力:基礎值、空軍科技、空戰專家,再被防空削減。 */ - fun strikePower(session: Session, nationId: Int, mission: AirMission, target: TargetClass, tile: Int): Float { - val base = mission.strikeAgainst(target).toFloat() - if (base <= 0f) return 0f - var p = base - p *= 1f + session.nations[nationId].techBonus(TechBranch.AIR) / 100f + /** 一次打擊的攻擊%:空軍科技、空戰專家,再被防空削減。 */ + fun strikePercent(session: Session, nationId: Int, tile: Int): Float { + var p = 100f + session.nations[nationId].techBonus(TechBranch.AIR) if (hasAirExpertNear(session, nationId, tile)) p *= 1.2f return Combat.flakFactor(flakAt(session, nationId, tile)) * p } @@ -252,24 +255,20 @@ object AirOps { session.map.distance(it.tile, tile) <= AIR_EXPERT_RANGE } - /** 不改變狀態的傷害預測(原始值,尚未依編制換算)。AI 拿它挑目標。 */ - fun previewDamage(session: Session, nationId: Int, mission: AirMission, defender: ArmyUnit): Int { - val power = strikePower(session, nationId, mission, defender.kind.targetClass, defender.tile) - return Combat.previewAirStrike(power, defenceOf(session, defender)) - } + /** 不改變狀態的傷害預測(平均擲骰)。AI 拿它挑目標。 */ + fun previewDamage(session: Session, nationId: Int, mission: AirMission, defender: ArmyUnit): Int = + hitOn(session, nationId, mission, defender, (mission.attackMin + mission.attackMax) / 2).toUnit - private fun defenceOf(session: Session, defender: ArmyUnit): Float { - // 在天上往下打,地形只剩一半的遮蔽效果;城牆與築壕照算。 - val terrain = session.map.terrainAt(defender.tile).defenceBonus / 2 - val pid = session.cityProvinceAt(defender.tile) - val city = if (pid >= 0 && session.holdsCity(defender, pid)) session.map.provinces[pid].cityDefenceBonus else 0 - return Combat.airDefence( + private fun hitOn(session: Session, nationId: Int, mission: AirMission, defender: ArmyUnit, roll: Int): Combat.Hit = + Combat.airHit( + roll, + strikePercent(session, nationId, defender.tile), + mission.versus(defender.kind.targetClass), defender, session.nations[defender.nationId].techBonus(defender.kind.branch), - terrain + city, - session.isEmbarked(defender) + session.isEmbarked(defender), + session.cityShields(defender) ) - } /** 出擊。不合法就回 null,不會留下半套狀態。 */ fun fly(session: Session, nationId: Int, mission: AirMission, tile: Int): AirResult? { @@ -294,7 +293,7 @@ object AirOps { ?: return AirResult(0, 0, false, null) // 對著防空跳傘要付出代價,但不會整支掉光 —— 那是轟炸機的工作。 val loss = (flakAt(session, nationId, tile) / DROP_FLAK_LOSS_DIVISOR).coerceAtMost(DROP_FLAK_MAX_LOSS) - unit.hp = (ArmyUnit.MAX_HP - loss).coerceAtLeast(1) + unit.hp = (unit.maxHp - unit.maxHp * loss / 100).coerceAtLeast(1) session.pushEvent( "event_airdrop", listOf(session.nations[nationId].nameKey), @@ -310,8 +309,8 @@ object AirOps { if (defender == null) { val pid = cityTarget(session, nationId, mission, tile) if (pid < 0) return AirResult(0, 0, false, null) - val power = strikePower(session, nationId, mission, TargetClass.ARMOURED, tile) - val damage = Combat.airStrikeCity(power, session.map.provinces[pid].cityDefenceBonus, session.rng) + val roll = session.rng.range(mission.attackMin, mission.attackMax) + val damage = Combat.airStrikeCity(roll, strikePercent(session, nationId, tile), mission.versusCity) val dealt = session.damageCity(pid, damage) session.pushEvent( "event_city_shelled", @@ -322,16 +321,12 @@ object AirOps { return AirResult(0, dealt, false, null) } - val power = strikePower(session, nationId, mission, defender.kind.targetClass, tile) - val raw = Combat.airStrike(power, defenceOf(session, defender), session.rng) // 跟地面攻擊同一套城牆規則:駐軍吃一半,城防另外吃一份。 - val inCity = session.cityShields(defender) - val toUnit = if (inCity) (raw * Combat.UNIT_DAMAGE_IN_CITY_PERCENT / 100).coerceAtLeast(1) else raw - val toCity = if (inCity) raw * Combat.CITY_DAMAGE_PERCENT / 100 else 0 + val hit = hitOn(session, nationId, mission, defender, session.rng.range(mission.attackMin, mission.attackMax)) val before = defender.hp - defender.damage(toUnit) + defender.damage(hit.toUnit) val lost = before - defender.hp - val dealtCity = session.damageCity(session.cityProvinceAt(defender.tile), toCity) + val dealtCity = session.damageCity(session.cityProvinceAt(defender.tile), hit.toCity) val defenderNation = session.nations[defender.nationId] session.pushEvent( diff --git a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/game/Orders.kt b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/game/Orders.kt index 8e71ba9..7c74d4a 100644 --- a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/game/Orders.kt +++ b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/game/Orders.kt @@ -253,7 +253,6 @@ object Orders { if (pid < 0) return null val damage = Combat.cityStrike( attacker, - session.map.provinces[pid].cityDefenceBonus, session.nations[attacker.nationId].techBonus(attacker.kind.branch), session.commandAura(attacker), session.moraleOf(attacker), @@ -298,7 +297,7 @@ object Orders { for (domain in Domain.values()) { val target = session.unitAt(targetTile, domain) ?: continue if (!session.isHostile(target.nationId, attacker.nationId)) continue - if (attacker.kind.attackAgainst(target.kind.targetClass) <= 0) continue + if (attacker.kind.versus(target.kind.targetClass) <= 0) continue if (attacker.nationId == session.playerNationId && !session.isUnitVisibleToPlayer(target)) continue return target } @@ -346,7 +345,7 @@ object Orders { if (attacker.hasAttacked || !attacker.isAlive || attacker.isLoaded) return -1 if (!attacker.kind.canAttack) return -1 if (session.isDisrupted(attacker)) return -1 - if (attacker.kind.attackAgainst(TargetClass.ARMOURED) <= 0) return -1 + if (Combat.versusCity(attacker.kind) <= 0) return -1 if (!Combat.canReach(attacker, session.map.distance(attacker.tile, tile))) return -1 if (session.anyUnitAt(tile)) return -1 val pid = session.cityProvinceAt(tile) @@ -376,26 +375,15 @@ object Orders { fun buildContext(session: Session, attacker: ArmyUnit, defender: ArmyUnit): CombatContext { val map = session.map - val defenderProvince = map.provinceAt(defender.tile) - val defenderCityBonus = if ( - defenderProvince != null && - defenderProvince.capitalTile == defender.tile && - defender.kind.domain == Domain.LAND && - session.holdsCity(defender, defenderProvince.id) - ) defenderProvince.cityDefenceBonus else 0 - // 城防已經被打光的城市不再替駐軍擋傷害 —— 那才是「城破了」的意思。 val defenderInCity = session.cityShields(defender) val attackerInCity = session.cityShields(attacker) - val defenderTerrain = map.terrainAt(defender.tile).defenceBonus - val attackerTerrain = map.terrainAt(attacker.tile).defenceBonus - return CombatContext( attacker = attacker, defender = defender, - defenderTerrainBonus = defenderTerrain + defenderCityBonus, - attackerTerrainBonus = attackerTerrain, + defenderTerrain = map.terrainAt(defender.tile), + attackerTerrain = map.terrainAt(attacker.tile), distance = map.distance(attacker.tile, defender.tile), attackerTech = session.nations[attacker.nationId].techBonus(attacker.kind.branch), defenderTech = session.nations[defender.nationId].techBonus(defender.kind.branch), @@ -587,7 +575,8 @@ object Orders { if (tile < 0) return null val nation = session.nations[nationId] val unit = session.spawnUnit(kind, nationId, tile) ?: return null - unit.size = size.coerceIn(1, ArmyUnit.MAX_SIZE) + unit.size = size + unit.restoreFullHp() nation.funds -= buildCost(kind, unit.size) session.pushEvent("event_unit_built", listOf(kind.key, province.nameKey), tile, nationId) return unit @@ -607,14 +596,15 @@ object Orders { return true } - /** 花錢就地補血。前線修不滿,只能靠城市。 */ + /** 花錢就地補血:補滿的價錢是造價的 100/180,依缺多少比例算。前線修不滿,只能靠城市。 */ fun repairCost(unit: ArmyUnit): Int { - val missing = ArmyUnit.MAX_HP - unit.hp - return (buildCost(unit.kind, unit.size) * missing) / 180 + val missing = (unit.maxHp - unit.hp).coerceAtLeast(0) + if (unit.maxHp <= 0) return 0 + return (buildCost(unit.kind, unit.size).toLong() * missing * 100 / unit.maxHp / 180).toInt() } fun canRepair(session: Session, unit: ArmyUnit): Boolean { - if (unit.hp >= ArmyUnit.MAX_HP) return false + if (unit.hp >= unit.maxHp) return false if (!session.isSupplySource(unit.tile, unit.nationId)) return false return session.nations[unit.nationId].funds >= repairCost(unit) } @@ -622,7 +612,7 @@ object Orders { fun repair(session: Session, unit: ArmyUnit): Boolean { if (!canRepair(session, unit)) return false session.nations[unit.nationId].funds -= repairCost(unit) - unit.hp = ArmyUnit.MAX_HP + unit.restoreFullHp() unit.resupply(ArmyUnit.MAX_SUPPLY) unit.movesLeft = 0 unit.hasAttacked = true diff --git a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/game/SaveGame.kt b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/game/SaveGame.kt index 9af08f5..aa88fb0 100644 --- a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/game/SaveGame.kt +++ b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/game/SaveGame.kt @@ -27,9 +27,10 @@ object SaveGame { private const val FILE_NAME = "session.save" /** * 存檔格式版本。規則改變了索引的意義時要加一,舊版存檔會被當成過時拒絕: - * 2 = 國家順序改成玩家第一(國家 id 全部位移)。 + * 2 = 國家順序改成玩家第一(國家 id 全部位移); + * 3 = 部隊與城防的 HP 改成絕對值(舊存檔裡是 0..100 的百分比)。 */ - private const val VERSION = 2 + private const val VERSION = 3 /** 讀檔的結果。存檔舊了跟存檔壞了是兩回事,玩家該看到不同的說明。 */ sealed class Restore { @@ -259,8 +260,10 @@ object SaveGame { val tile = p[3].toIntOrNull() ?: continue if (tile !in 0 until map.tileCount) continue val unit = ArmyUnit(id, kind, nationId, tile) - unit.hp = p[4].toIntOrNull() ?: ArmyUnit.MAX_HP - unit.level = (p[5].toIntOrNull() ?: 1).coerceIn(1, ArmyUnit.MAX_LEVEL) + // 等級與編制會按比例縮放 HP,所以先設它們,最後才寫入存下來的 HP。 + unit.level = p[5].toIntOrNull() ?: 1 + unit.size = if (p.size >= 15) (p[14].toIntOrNull() ?: 1) else 1 + unit.hp = (p[4].toIntOrNull() ?: unit.maxHp).coerceIn(1, unit.maxHp) unit.exp = p[6].toIntOrNull() ?: 0 unit.supply = p[7].toIntOrNull() ?: ArmyUnit.MAX_SUPPLY unit.movesLeft = p[8].toIntOrNull() ?: 0 @@ -269,8 +272,6 @@ object SaveGame { unit.rumour = (p[11].toIntOrNull() ?: 0).coerceIn(0, ArmyUnit.MAX_RUMOUR) unit.transportId = p[12].toIntOrNull() ?: -1 unit.commanderId = if (p[13] == "-") "" else p[13] - // 舊存檔沒有編制這一欄,那就是一個編制。 - unit.size = if (p.size >= 15) (p[14].toIntOrNull() ?: 1).coerceIn(1, ArmyUnit.MAX_SIZE) else 1 session.restoreUnit(unit) } session.rebuildCargoLinks() diff --git a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/game/Session.kt b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/game/Session.kt index 39ac33e..56c1e00 100644 --- a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/game/Session.kt +++ b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/game/Session.kt @@ -527,7 +527,7 @@ class Session( if (supplied) { unit.resupply(RESUPPLY_RATE + logistics / 4) val repair = repairRate(unit) - if (repair > 0 && unit.hp < ArmyUnit.MAX_HP) unit.heal(repair) + unit.healPercent(repair) } else { // 軍艦出港時就帶著幾週的油料與糧食,補給斷了會變弱,不會沉。 // 陸軍不一樣:彈藥與糧食是每天要送上去的,斷了就會餓死。 @@ -537,7 +537,7 @@ class Session( val floor = if (ship) ArmyUnit.SUPPLY_CRITICAL else 0 unit.supply = (unit.supply - drain).coerceAtLeast(floor) // 完全斷補給就開始失血 —— 深入敵境的孤軍必須有代價。 - if (unit.supply <= 0) unit.damage(STARVATION_DAMAGE) + if (unit.supply <= 0) unit.losePercent(STARVATION_PERCENT) } unit.decayRumour() if (!unit.isAlive) starved.add(unit) @@ -866,7 +866,7 @@ class Session( } } if (threatened) continue - cityHp[province.id] = (cityHp[province.id] + CITY_DEFENCE_REPAIR).coerceAtMost(max) + cityHp[province.id] = (cityHp[province.id] + max * CITY_DEFENCE_REPAIR_PERCENT / 100).coerceAtMost(max) } } @@ -1003,7 +1003,7 @@ class Session( val kind = UnitKind.byName(su.kindName) ?: continue if (!map.inBounds(su.col, su.row)) continue spawnUnit(kind, nationId, map.index(su.col, su.row), su.level, su.commanderId) - ?.let { it.size = su.size.coerceIn(1, ArmyUnit.MAX_SIZE) } + ?.let { it.size = su.size } } // 開局的部隊立刻可以行動。 for (unit in units) { @@ -1040,20 +1040,23 @@ class Session( * 十幾回合後會自己沉光 —— 橫渡大洋因此是不可能的。 */ const val NAVAL_ATTRITION_RATE = 7 - const val STARVATION_DAMAGE = 8 + /** 完全斷補給時每回合掉的 HP,最大 HP 的百分比。 */ + const val STARVATION_PERCENT = 8 + + /** 有補給時每回合回復的 HP,最大 HP 的百分比:城裡快、野外慢。 */ const val CITY_REPAIR = 22 const val FIELD_REPAIR = 6 /** - * 有守軍時城防每回合的修復量。 + * 有守軍時城防每回合的修復量,最大城防的百分比。 * - * 比舊值高,因為現在要留一支部隊才修得動 —— 條件變嚴了,速度就該 - * 補回來一點。仍然低於一次砲擊的傷害,所以圍攻不會被修復追平。 + * 要留一支部隊、而且四周沒有敵人才修得動,所以圍攻中的城不會自己長回來; + * 這個速度只決定「打下來之後多久才重新變成堡壘」。 * * 注意跟 [CITY_REPAIR] 不是同一件事:那個是「城市替駐軍回血」, * 這個是「駐軍替城市補城防」。兩個方向都有,互為代價。 */ - const val CITY_DEFENCE_REPAIR = 12 + const val CITY_DEFENCE_REPAIR_PERCENT = 5 /** 易主之後城防剩下的比例:新主人接手的是一座殘城。 */ const val CITY_HP_AFTER_CAPTURE_PERCENT = 35 diff --git a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/render/HudRenderer.kt b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/render/HudRenderer.kt index f60784f..d8c8e53 100644 --- a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/render/HudRenderer.kt +++ b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/render/HudRenderer.kt @@ -216,11 +216,17 @@ class HudRenderer(private val session: Session) { val title = province?.let { Strings.byName(it.nameKey) } ?: Strings.byName(terrain.key) Widgets.leftFit(canvas, title, x, top + Ui.dp(16f), Ui.dp(12.5f), Ui.dp(130f), ink, bold = true) - val terrainLine = Strings.format( - R.string.hud_terrain_line, - Strings.byName(terrain.key), - terrain.defenceBonus - ) + // 地形只懲罰攻方的裝甲與火炮;沒有懲罰的地形只顯示名稱。 + val terrainLine = if (terrain.hasAttackPenalty) { + Strings.format( + R.string.hud_terrain_line, + Strings.byName(terrain.key), + terrain.armourPenalty, + terrain.artilleryPenalty + ) + } else { + Strings.byName(terrain.key) + } Widgets.leftFit(canvas, terrainLine, x, top + Ui.dp(31f), Ui.dp(10.5f), Ui.dp(130f), dim) if (province != null) { @@ -286,7 +292,7 @@ class HudRenderer(private val session: Session) { val barX = nameX + Ui.dp(126f) val barWidth = Ui.dp(84f) rect.set(barX, top + Ui.dp(12f), barX + barWidth, top + Ui.dp(19f)) - val hpRatio = unit.hp / ArmyUnit.MAX_HP.toFloat() + val hpRatio = unit.hpRatio Widgets.bar(canvas, rect, hpRatio, Palette.healthColour(hpRatio)) Widgets.left(canvas, "${unit.hp}", barX + barWidth + Ui.dp(5f), top + Ui.dp(19f), Ui.dp(10f), dim) diff --git a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/render/MapRenderer.kt b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/render/MapRenderer.kt index ccc9b9c..12db8c0 100644 --- a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/render/MapRenderer.kt +++ b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/render/MapRenderer.kt @@ -487,8 +487,8 @@ class MapRenderer(private val session: Session) { } // 血條。滿血就不畫 —— 地圖上該只有「出事了」的部隊會吸引注意力。 - if (unit.hp < ArmyUnit.MAX_HP && size >= Ui.dp(9f)) { - val ratio = unit.hp / ArmyUnit.MAX_HP.toFloat() + if (unit.hp < unit.maxHp && size >= Ui.dp(9f)) { + val ratio = unit.hpRatio rect.set(cx - w / 2f, top + h + size * 0.04f, cx + w / 2f, top + h + size * 0.16f) Widgets.bar(canvas, rect, ratio, Palette.healthColour(ratio)) } diff --git a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/render/PanelRenderer.kt b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/render/PanelRenderer.kt index 8c8e6ea..3c6381e 100644 --- a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/render/PanelRenderer.kt +++ b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/render/PanelRenderer.kt @@ -20,6 +20,7 @@ import io.github.acidefluorhydrique.mapconquer.game.Orders import io.github.acidefluorhydrique.mapconquer.game.Session import io.github.acidefluorhydrique.mapconquer.game.SessionStatus import io.github.acidefluorhydrique.mapconquer.units.ArmyUnit +import io.github.acidefluorhydrique.mapconquer.units.Combat import io.github.acidefluorhydrique.mapconquer.units.TargetClass import io.github.acidefluorhydrique.mapconquer.units.TechBranch import io.github.acidefluorhydrique.mapconquer.units.UnitKind @@ -217,8 +218,10 @@ class PanelRenderer(private val session: Session) { val detail = if (enabled) { Strings.format( R.string.panel_production_stats, - kind.attackAgainst(TargetClass.SOFT), + kind.attackMin, + kind.attackMax, kind.defence, + ArmyUnit.maxHpFor(kind, productionSize, 1), kind.movement ) } else { @@ -562,10 +565,10 @@ class PanelRenderer(private val session: Session) { canvas, Strings.get(labels[i]), panel.left + Ui.dp(24f), y, Ui.dp(10.5f), Ui.dp(90f), dim ) - val value = unit.kind.attack[i] + val value = unit.kind.attackAgainst(TargetClass.values()[i]) inner.set(panel.left + Ui.dp(110f), y - Ui.dp(8f), panel.left + Ui.dp(230f), y - Ui.dp(1f)) Widgets.bar( - canvas, inner, value / 64f, + canvas, inner, value / 70f, if (value > 0) Colors.of("#C4553C") else Colors.of("#3A4450") ) Widgets.left(canvas, value.toString(), panel.left + Ui.dp(236f), y, Ui.dp(10.5f), ink) @@ -574,7 +577,11 @@ class PanelRenderer(private val session: Session) { val stats = Strings.format( R.string.unit_stat_line, - unit.kind.defence, session.movementFor(unit), + unit.hp, unit.maxHp, + Combat.effectiveDefence( + unit, session.nations[unit.nationId].techBonus(unit.kind.branch), session.isEmbarked(unit) + ), + session.movementFor(unit), unit.kind.minRange.coerceAtLeast(1), unit.kind.maxRange, unit.kind.vision ) Widgets.leftFit( diff --git a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/units/ArmyUnit.kt b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/units/ArmyUnit.kt index e674b5a..49c4a50 100644 --- a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/units/ArmyUnit.kt +++ b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/units/ArmyUnit.kt @@ -11,8 +11,8 @@ package io.github.acidefluorhydrique.mapconquer.units * 這裡只放屬於這支部隊自己的狀態。這條界線讓存檔變得單純 —— * 存下這個物件的欄位就夠還原,不必連帶存一堆推導值。 * - * HP 一律 0..100。不同兵種的耐打程度靠 [UnitKind.defence] 表達, - * 而不是靠不同的血量上限 —— 統一血量讓「還剩幾成」在 UI 上一眼可比。 + * HP 是絕對值,跟參考遊戲一樣:最大生命由兵種、編制與等級決定(見 [maxHpFor]), + * 所以一輛戰車天生就比一班步兵耐打,血條上的數字也能直接跟傷害比。 */ class ArmyUnit( val id: Int, @@ -20,20 +20,38 @@ class ArmyUnit( var nationId: Int, var tile: Int ) { - var hp: Int = MAX_HP - /** * 編制數 1..[MAX_SIZE]。 * - * HP 仍然是 0..100,但它是「這個編制還剩幾成」,不是絕對血量。大編制的 - * 耐打靠 [scaledDamage] 表達:同一發傷害,三個編制掉的比例比一個編制少。 - * 這樣補給、整補、血條這些以百分比運作的東西全部不必改。 + * 改動編制或等級時,目前 HP 依比例跟著最大 HP 縮放 —— 參考遊戲在載入關卡、 + * 重算最大 HP 時也是這樣做的。所以存檔與劇本要先設編制與等級,再設 HP。 */ var size: Int = 1 + set(value) { + val before = maxHp + field = value.coerceIn(1, MAX_SIZE) + rescaleHp(before) + } var level: Int = 1 + set(value) { + val before = maxHp + field = value.coerceIn(1, MAX_LEVEL) + rescaleHp(before) + } + var exp: Int = 0 + val maxHp: Int get() = maxHpFor(kind, size, level) + + var hp: Int = maxHpFor(kind, 1, 1) + + /** 目前 HP 佔最大 HP 的比例,血條與防空估算用。 */ + val hpRatio: Float get() = if (maxHp <= 0) 0f else hp / maxHp.toFloat() + + /** 目前 HP 的百分比(整數,無條件捨去)。 */ + val hpPercent: Int get() = if (maxHp <= 0) 0 else hp * 100 / maxHp + /** 0..100。低於 [SUPPLY_STRAINED] 開始掉戰力,歸零會逐回合失血。 */ var supply: Int = MAX_SUPPLY @@ -119,18 +137,28 @@ class ArmyUnit( } fun damage(amount: Int) { - hp = (hp - scaledDamage(amount)).coerceAtLeast(0) + if (amount > 0) hp = (hp - amount).coerceAtLeast(0) + } + + /** 回復最大 HP 的 [percent]%(至少 1 點),不超過上限。 */ + fun healPercent(percent: Int) { + if (percent <= 0 || hp >= maxHp) return + hp = (hp + (maxHp * percent / 100).coerceAtLeast(1)).coerceAtMost(maxHp) } - /** 一發原始傷害落在這個編制上,實際會掉幾成。 */ - fun scaledDamage(raw: Int): Int { - if (raw <= 0) return 0 - val percent = hpPercent(size) - return ((raw * 100 + percent - 1) / percent).coerceAtLeast(1) + /** 扣掉最大 HP 的 [percent]%(至少 1 點)。 */ + fun losePercent(percent: Int) { + if (percent > 0) damage((maxHp * percent / 100).coerceAtLeast(1)) } - fun heal(amount: Int) { - hp = (hp + amount).coerceAtMost(MAX_HP) + fun restoreFullHp() { + hp = maxHp + } + + private fun rescaleHp(beforeMax: Int) { + val after = maxHp + if (beforeMax <= 0 || after == beforeMax || hp <= 0) return + hp = (hp.toLong() * after / beforeMax).toInt().coerceIn(1, after) } fun resupply(amount: Int) { @@ -138,33 +166,41 @@ class ArmyUnit( } companion object { - const val MAX_HP = 100 const val MAX_SUPPLY = 100 const val MAX_LEVEL = 5 const val MAX_SIZE = 4 /** - * 編制的攻擊與耐打倍率(百分比)。 + * 編制的攻擊與生命倍率(百分比),取參考遊戲的數值。 * * 兩條曲線都遞增、但每一格都追不上「拆開的同樣幾支」:兩個一編制的部隊 - * 合計 200% 攻擊、200% 耐打,併成一個兩編制只剩 130% 與 160%。所以併編 + * 合計 200% 攻擊、200% 生命,併成一個兩編制只剩 125% 與 160%。所以併編 * 永遠有代價 —— 它換到的是「一格裡的集中」,而在一格一支部隊的規則下, * 那是守窄正面、或打動單支部隊打不動的目標唯一的方法。 */ fun attackPercent(size: Int): Int = when (size.coerceIn(1, MAX_SIZE)) { 1 -> 100 - 2 -> 130 - 3 -> 155 + 2 -> 125 + 3 -> 150 else -> 175 } - fun hpPercent(size: Int): Int = when (size.coerceIn(1, MAX_SIZE)) { + fun sizeHpPercent(size: Int): Int = when (size.coerceIn(1, MAX_SIZE)) { 1 -> 100 2 -> 160 - 3 -> 215 - else -> 265 + 3 -> 210 + else -> 250 } + /** 每升一級加的生命、攻擊(加在擲骰之外)與防禦,取參考遊戲的級距。 */ + const val LEVEL_HP = 20 + const val LEVEL_ATTACK = 4 + const val LEVEL_DEFENCE = 2 + + /** 最大 HP = 兵種基礎 × 編制倍率(無條件捨去),再加等級加成。 */ + fun maxHpFor(kind: UnitKind, size: Int, level: Int): Int = + kind.hp * sizeHpPercent(size) / 100 + LEVEL_HP * (level.coerceIn(1, MAX_LEVEL) - 1) + /** 低於這個補給開始掉戰力。 */ const val SUPPLY_STRAINED = 40 const val SUPPLY_CRITICAL = 20 diff --git a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/units/Combat.kt b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/units/Combat.kt index 1a84e58..30a5701 100644 --- a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/units/Combat.kt +++ b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/units/Combat.kt @@ -4,7 +4,7 @@ package io.github.acidefluorhydrique.mapconquer.units import io.github.acidefluorhydrique.mapconquer.core.Rng -import kotlin.math.roundToInt +import io.github.acidefluorhydrique.mapconquer.world.Terrain /** * 一次交戰的完整輸入。 @@ -16,10 +16,10 @@ import kotlin.math.roundToInt class CombatContext( val attacker: ArmyUnit, val defender: ArmyUnit, - /** 守方所在地形 + 城市的防禦加成總和(百分比)。 */ - val defenderTerrainBonus: Int, - /** 攻方所在地形的防禦加成,反擊時才用得到。 */ - val attackerTerrainBonus: Int, + /** 守方站的地形:決定裝甲與火炮攻方的懲罰。 */ + val defenderTerrain: Terrain = Terrain.PLAIN, + /** 攻方站的地形,反擊時換它當守方地形。 */ + val attackerTerrain: Terrain = Terrain.PLAIN, /** 距離 1 是貼身,> 1 是遠射(守方不還手)。 */ val distance: Int, /** 攻方所屬國家在該兵種分支上的科技加成(百分比)。 */ @@ -31,8 +31,8 @@ class CombatContext( /** * 陸軍是否正在浮渡。 * - * 下了海的陸軍等於擠在運輸駁船上:防禦幾乎歸零、火力大打折扣、 - * 兵種技能全部失效。這讓「制海權」變成真的有意義 —— + * 下了海的陸軍等於擠在運輸駁船上:沒有防禦、火力大打折扣、 + * 還不了手。這讓「制海權」變成真的有意義 —— * 沒有護航就渡海,是把整支部隊送給對方的艦隊。 */ val attackerAtSea: Boolean = false, @@ -41,15 +41,32 @@ class CombatContext( val attackerMorale: Int = 0, val defenderMorale: Int = 0, /** - * 這一方是否站在城市格上。 + * 這一方是否站在城防還在的城市格上。 * - * 站在城裡的部隊由城防替它挨一部分:部隊只吃 50%,城市另外吃 70%。 - * 兩者加起來大於 100 是刻意的 —— 城市不是把傷害轉走,是替駐軍多擋一層, - * 代價是自己會被打垮。所以攻城的節奏是先磨掉城防,再解決守軍。 + * 站在城裡的部隊只吃一半,城防另外挨一份(見 [Combat.resolve])。 */ val attackerInCity: Boolean = false, val defenderInCity: Boolean = false -) +) { + /** 角色互換:反擊用的就是這個。 */ + fun swapped(): CombatContext = CombatContext( + attacker = defender, + defender = attacker, + defenderTerrain = attackerTerrain, + attackerTerrain = defenderTerrain, + distance = distance, + attackerTech = defenderTech, + defenderTech = attackerTech, + attackerAura = defenderAura, + defenderAura = attackerAura, + attackerAtSea = defenderAtSea, + defenderAtSea = attackerAtSea, + attackerMorale = defenderMorale, + defenderMorale = attackerMorale, + attackerInCity = defenderInCity, + defenderInCity = attackerInCity + ) +} /** 一次交戰的結果。UI 用它播動畫,AI 用它評估要不要打。 */ class CombatResult( @@ -65,98 +82,211 @@ class CombatResult( ) /** - * 戰鬥判定。 + * 戰鬥判定。規格見 docs/original-behavior.md 第 6 節。 * - * 公式是「攻防比」而不是「攻減防」: + * 一擊的傷害: * - * 傷害 = 55 * A / (A + D) + * 攻擊值 = 等級固定攻擊 + ⌊攻擊% × 擲骰⌋ 擲骰 ∈ [最小攻擊, 最大攻擊] 均勻整數 + * 傷害 = ⌊攻擊值 × 1000 / (防禦 × 16 + 1000)⌋ + * 傷害 = ⌊傷害 × 兵種相剋 × 指揮官⌋ + * 傷害 = ⌊傷害 × 地形(城防還在時改成 0.5,浮渡中改成 1.5)⌋ + * 傷害 = ⌊傷害 × (100 − 減傷) / 100⌋,最少 1 * - * 選比值的理由是它天然有界。相減的公式在高攻低防時會爆炸(一擊抹平), - * 在低攻高防時又會完全歸零(打不動,玩家只能乾等), - * 兩端都會讓中後期的數值成長變成「開關」而不是「曲線」。 - * 比值公式讓 2:1 的優勢永遠是 2:1 的手感,不管雙方數字多大。 + * 防禦的作用是「每 1 點防禦 = 分母多 1.6%」:防禦 62.5 讓傷害減半,永遠不會歸零。 + * 這跟參考遊戲一致;兵種相剋、補給、築壕、指揮官是本專案自己的規則, + * 以乘數的形式插在參考公式的對應位置上。 * - * 亂數只有 ±8%,而且走 [Rng] 這條決定性的路: + * 亂數只用在擲骰這一步,而且走 [Rng] 這條決定性的路: * 玩家需要能預期結果,AI 需要能推演結果,重播需要能重現結果。 */ object Combat { /** - * 士氣對輸出的乘數。 + * 士氣對攻擊% 的乘數(百分比)。+1 / −1 / −2 取參考遊戲的 125 / 75 / 50。 + * 混亂(−3)在參考遊戲裡是「不能攻擊也不能反擊」,這裡直接給 0。 + */ + fun moralePercent(level: Int): Int = when { + level >= 1 -> 125 + level == 0 -> 100 + level == -1 -> 75 + level == -2 -> 50 + else -> 0 + } + + /** HP 低於這個百分比,攻擊% 減半(參考遊戲的步兵、裝甲、火炮、海軍都是這條)。 */ + const val WEAKENED_HP_PERCENT = 50 + const val WEAKENED_ATTACK_PERCENT = 50 + + /** 站在城裡、城防還在的部隊,吃到的傷害比例。 */ + const val UNIT_DAMAGE_IN_CITY_PERCENT = 50 + + /** 城防在一次交火(守方有還手)中承受的比例;沒有還手時整份都算。 */ + const val CITY_SHARE_IN_EXCHANGE_PERCENT = 70 + + /** 浮渡中的陸軍只剩象徵性的自衛火力。本專案的規則。 */ + private const val AT_SEA_ATTACK_PERCENT = 30 + + /** + * 浮渡中的陸軍多挨的傷害(百分比)。本專案的規則。 * - * 高昂的加成小、低落的懲罰大 —— 挨打比佔便宜更有感,這樣「把對方圍起來」 - * 才會是划算的投資。混亂直接歸零:那一級的意思就是打不出去。 + * 參考公式裡防禦的份量很輕(步兵防禦 1 → 只差 1.6%),光把防禦歸零不足以讓 + * 「沒有護航就渡海」變成壞主意;參考遊戲是靠專打登船部隊的兵種特性做到這件事。 */ - fun moraleFactor(level: Int): Float = when { - level >= 1 -> 1.08f - level == 0 -> 1f - level == -1 -> 0.85f - level == -2 -> 0.65f - else -> 0f + private const val AT_SEA_DAMAGE_TAKEN_PERCENT = 150 + + /** 每級築壕減多少傷害(百分比)。本專案的規則。 */ + const val ENTRENCH_REDUCTION_PER_LEVEL = 5 + + private const val DEFENCE_WEIGHT = 16 + private const val DEFENCE_BASE = 1000 + + // ------------------------------------------------------------------ + // 公式的零件 + // ------------------------------------------------------------------ + + /** + * 攻擊%:士氣 × 編制 × 殘血門檻 × 科技與光環 × 補給 × 浮渡。 + * + * [hp] 分開傳是為了反擊:守方先挨了一擊,反擊時的殘血門檻要看挨打之後的 HP, + * 而 AI 推演時不能真的去扣它的血。 + */ + fun attackPercent(unit: ArmyUnit, hp: Int, morale: Int, tech: Int, aura: Int, atSea: Boolean): Float { + var pct = moralePercent(morale).toFloat() + if (pct <= 0f) return 0f + pct = pct * ArmyUnit.attackPercent(unit.size) / 100f + if (unit.maxHp > 0 && hp * 100 < unit.maxHp * WEAKENED_HP_PERCENT) pct = pct * WEAKENED_ATTACK_PERCENT / 100f + pct *= 1f + (tech + aura) / 100f + pct *= unit.supplyFactor + if (atSea) pct = pct * AT_SEA_ATTACK_PERCENT / 100f + return pct } - /** 均勢對打時的單回合傷害基準。調它等於調整整場戰爭的節奏。 */ - private const val DAMAGE_SCALE = 55f + /** 等級給的固定攻擊,加在擲骰之外,不受士氣與殘血放大。 */ + fun flatAttack(unit: ArmyUnit): Int = ArmyUnit.LEVEL_ATTACK * (unit.level - 1) - /** 反擊只有正面攻擊的六成 —— 主動出手必須是划算的。 */ - private const val RETALIATION_SCALE = 0.6f + /** + * 有效防禦:兵種防禦 + 等級防禦,再乘科技與補給。浮渡中歸零。 + * + * 參考遊戲的科技是加固定點數;本專案的科技是百分比,放在這裡的效果差不多: + * 防禦 10 的戰車點滿科技變成 14。 + */ + fun effectiveDefence(unit: ArmyUnit, tech: Int, atSea: Boolean): Int { + if (atSea) return 0 + val base = unit.kind.defence + ArmyUnit.LEVEL_DEFENCE * (unit.level - 1) + // 補給只影響一半的防禦:斷補給的守軍會變脆,但不會直接被輾平。 + val supplied = 0.5f + 0.5f * unit.supplyFactor + return (base * (1f + tech / 100f) * supplied).toInt() + } - /** 站在城裡的部隊實際吃到的傷害比例。 */ - const val UNIT_DAMAGE_IN_CITY_PERCENT = 50 + /** 攻方是裝甲或火炮時,守方所在地形扣掉的比例;其他兵種不受地形影響。 */ + fun terrainFactor(attacker: ArmyUnit, defenderTerrain: Terrain, attackerAtSea: Boolean): Float { + if (attackerAtSea) return 1f + val penalty = when (attacker.kind.branch) { + TechBranch.ARMOUR -> defenderTerrain.armourPenalty + TechBranch.ARTILLERY -> defenderTerrain.artilleryPenalty + else -> 0 + } + return 1f - penalty / 100f + } + + /** 守方的減傷(百分比):築壕與防守型指揮官。本專案的規則。 */ + fun damageReduction(defender: ArmyUnit): Int { + var r = defender.entrenchment * ENTRENCH_REDUCTION_PER_LEVEL + val cmd = defender.commander + if (cmd != null) { + if (cmd.has(CommanderSkill.DEFENSIVE)) r += 13 + if (cmd.has(CommanderSkill.FORTRESS) && defender.entrenchment >= 2) r += 17 + } + return r.coerceIn(0, 90) + } + + /** 攻擊值 × 1000 / (防禦 × 16 + 1000),整數除法。 */ + fun defended(attackValue: Int, defence: Int): Int = + attackValue * DEFENCE_BASE / (defence.coerceAtLeast(0) * DEFENCE_WEIGHT + DEFENCE_BASE) + + // ------------------------------------------------------------------ + // 一擊 + // ------------------------------------------------------------------ + + /** 一擊打在部隊與城防上各是多少。 */ + class Hit(val toUnit: Int, val toCity: Int) + + private val NO_HIT = Hit(0, 0) - /** 同一次攻擊另外扣在城防上的比例。兩者相加大於 100 是刻意的。 */ - const val CITY_DAMAGE_PERCENT = 70 - - /** 空城的等效防禦基準,再乘上該城的守備加成。 */ - private const val CITY_STRIKE_DEFENCE_BASE = 26f - - fun attackPower(ctx: CombatContext): Float { - val u = ctx.attacker - val base = u.kind.attackAgainst(ctx.defender.kind.targetClass).toFloat() - if (base <= 0f) return 0f - var p = base - p *= 1f + 0.06f * (u.level - 1) - p *= ArmyUnit.attackPercent(u.size) / 100f - p *= 1f + ctx.attackerTech / 100f - p *= 1f + ctx.attackerAura / 100f - p *= commanderAttackFactor(u, ctx.defender) - p *= u.supplyFactor - p *= moraleFactor(ctx.attackerMorale) - // 浮渡中的陸軍只剩象徵性的自衛火力。 - if (ctx.attackerAtSea) p *= 0.3f - // 殘血部隊的輸出等比下降,但不會完全消失。 - p *= 0.35f + 0.65f * (u.hp / ArmyUnit.MAX_HP.toFloat()) - return p + /** + * 用給定的擲骰值 [roll] 算一擊。攻方的 HP 由 [attackerHp] 指定。 + * + * [exchange] = 這一擊屬於一次會還手的交火:城防只承受七成。 + */ + fun hit(ctx: CombatContext, roll: Int, attackerHp: Int, exchange: Boolean): Hit { + val a = ctx.attacker + val d = ctx.defender + val versus = a.kind.versus(d.kind.targetClass) + if (versus <= 0) return NO_HIT + val pct = attackPercent(a, attackerHp, ctx.attackerMorale, ctx.attackerTech, ctx.attackerAura, ctx.attackerAtSea) + if (pct <= 0f) return NO_HIT + val attackValue = flatAttack(a) + (pct / 100f * roll).toInt() + val bonus = versus / 100f * commanderAttackFactor(a, d) + + var dmg = defended(attackValue, effectiveDefence(d, ctx.defenderTech, ctx.defenderAtSea)) + dmg = (dmg * bonus).toInt() + val placement = when { + ctx.defenderAtSea -> AT_SEA_DAMAGE_TAKEN_PERCENT / 100f + ctx.defenderInCity -> UNIT_DAMAGE_IN_CITY_PERCENT / 100f + else -> terrainFactor(a, ctx.defenderTerrain, ctx.attackerAtSea) + } + dmg = (dmg * placement).toInt() + dmg = dmg * (100 - damageReduction(d)) / 100 + dmg = dmg.coerceAtLeast(1) + + var city = 0 + if (ctx.defenderInCity) { + // 城防沒有防禦,也沒有地形:同一擲骰直接打在牆上,再看是不是交火。 + city = (defended(attackValue, 0) * cityFactor(a)).toInt() + if (exchange) city = city * CITY_SHARE_IN_EXCHANGE_PERCENT / 100 + city = city.coerceAtLeast(1) + } + return Hit(dmg, city) } + /** + * 打城牆的效果百分比:取「對步兵」與「對裝甲」兩欄較高的那一個。 + * + * 參考遊戲的城防是防禦 0 的目標,陸軍打它不打折;本專案有兵種相剋表, + * 取兩個陸上欄位的較高者,讓步兵、戰車、火炮都能拆牆,潛艇與防空炮不行。 + */ + fun versusCity(kind: UnitKind): Int = + maxOf(kind.versus(TargetClass.SOFT), kind.versus(TargetClass.ARMOURED)) + + private fun cityFactor(attacker: ArmyUnit): Float = versusCity(attacker.kind) / 100f + + /** 擲一次攻擊骰。 */ + fun roll(kind: UnitKind, rng: Rng): Int = rng.range(kind.attackMin, kind.attackMax) + + /** 平均擲骰,給預測用。 */ + private fun meanRoll(kind: UnitKind): Int = (kind.attackMin + kind.attackMax) / 2 + + // ------------------------------------------------------------------ + // 城市 + // ------------------------------------------------------------------ + /** * 直接轟擊一座沒有守軍的城市。 * - * 城市不還手、沒有士氣、也沒有兵種相剋 —— 它就是一個要被磨掉的數字, - * 所以用同一支比值公式,把守方換成城防加成。攻擊力取「對裝甲」那一欄: - * 城牆與碉堡是硬目標,反步兵的火力對它幫助有限。 + * 城防在參考遊戲裡是一支防禦 0 的「部隊」:不還手、沒有地形,所以傷害就是攻擊值本身。 */ - fun cityStrike( - attacker: ArmyUnit, - cityDefence: Int, - techBonus: Int, - auraBonus: Int, - morale: Int, - rng: Rng - ): Int { - val base = attacker.kind.attackAgainst(TargetClass.ARMOURED).toFloat() - if (base <= 0f) return 0 - var a = base - a *= 1f + 0.06f * (attacker.level - 1) - a *= ArmyUnit.attackPercent(attacker.size) / 100f - a *= 1f + techBonus / 100f - a *= 1f + auraBonus / 100f - a *= attacker.supplyFactor - a *= moraleFactor(morale) - a *= 0.35f + 0.65f * (attacker.hp / ArmyUnit.MAX_HP.toFloat()) - if (a <= 0f) return 0 - val d = CITY_STRIKE_DEFENCE_BASE * (1f + cityDefence / 100f) - return jitter(DAMAGE_SCALE * a / (a + d), rng).coerceAtLeast(1) + fun cityStrike(attacker: ArmyUnit, techBonus: Int, auraBonus: Int, morale: Int, rng: Rng): Int = + cityDamage(attacker, roll(attacker.kind, rng), techBonus, auraBonus, morale) + + fun previewCityStrike(attacker: ArmyUnit, techBonus: Int, auraBonus: Int, morale: Int): Int = + cityDamage(attacker, meanRoll(attacker.kind), techBonus, auraBonus, morale) + + private fun cityDamage(attacker: ArmyUnit, roll: Int, techBonus: Int, auraBonus: Int, morale: Int): Int { + if (versusCity(attacker.kind) <= 0) return 0 + val pct = attackPercent(attacker, attacker.hp, morale, techBonus, auraBonus, false) + if (pct <= 0f) return 0 + val attackValue = flatAttack(attacker) + (pct / 100f * roll).toInt() + return (defended(attackValue, 0) * cityFactor(attacker)).toInt().coerceAtLeast(1) } // ------------------------------------------------------------------ @@ -166,77 +296,54 @@ object Combat { /** * 防空把打擊削掉多少。 * - * 用「火力 / (火力 + 防空)」的同一種比值:一門滿血防空炮(52)讓打擊只剩 + * 「火力 / (火力 + 防空)」的比值:一門滿血防空炮(約 48)讓打擊只剩 * 約三分之二,兩門剩一半 —— 永遠不會歸零,但防空密集的地方就是不划算。 */ fun flakFactor(flak: Int): Float = if (flak <= 0) 1f else 100f / (100f + flak) /** - * 挨空襲時的防禦。 + * 空中打擊打在部隊上:同一條防禦公式,沒有地形懲罰(飛機不是裝甲也不是火炮), + * 城防還在時一樣只吃一半、城防整份承受(守方還不了手)。 * - * 跟 [defencePower] 同一組修正,只是沒有攻方可比:等級、科技、 - * 地形(由呼叫端先砍半)、築壕、補給與殘血。飛機打完就走,不會被還手。 + * [powerPercent] 是打擊的攻擊%(科技、空戰專家、防空削減都已乘進去)。 */ - fun airDefence(defender: ArmyUnit, techBonus: Int, coverBonus: Int, atSea: Boolean): Float { - var p = defender.kind.defence.toFloat() - p *= 1f + 0.05f * (defender.level - 1) - p *= 1f + techBonus / 100f - p *= 1f + (coverBonus + defender.entrenchBonus) / 100f - p *= 0.5f + 0.5f * defender.supplyFactor - p *= 0.45f + 0.55f * (defender.hp / ArmyUnit.MAX_HP.toFloat()) - if (atSea) p *= 0.15f - return p.coerceAtLeast(1f) - } - - fun previewAirStrike(power: Float, defence: Float): Int { - if (power <= 0f) return 0 - return (DAMAGE_SCALE * power / (power + defence)).roundToInt().coerceIn(1, ArmyUnit.MAX_HP) + fun airHit( + roll: Int, + powerPercent: Float, + versus: Int, + defender: ArmyUnit, + defenderTech: Int, + defenderAtSea: Boolean, + defenderInCity: Boolean + ): Hit { + if (versus <= 0 || powerPercent <= 0f) return NO_HIT + val attackValue = (powerPercent / 100f * roll).toInt() + var dmg = defended(attackValue, effectiveDefence(defender, defenderTech, defenderAtSea)) + dmg = (dmg * versus / 100f).toInt() + if (defenderAtSea) dmg = dmg * AT_SEA_DAMAGE_TAKEN_PERCENT / 100 + else if (defenderInCity) dmg = dmg * UNIT_DAMAGE_IN_CITY_PERCENT / 100 + dmg = (dmg * (100 - damageReduction(defender)) / 100).coerceAtLeast(1) + // 城牆沒有防禦,跟打部隊用同一欄。 + val city = if (defenderInCity) (attackValue * versus / 100).coerceAtLeast(1) else 0 + return Hit(dmg, city) } - fun airStrike(power: Float, defence: Float, rng: Rng): Int { - if (power <= 0f) return 0 - return jitter(DAMAGE_SCALE * power / (power + defence), rng).coerceIn(1, ArmyUnit.MAX_HP) + /** 轟炸一座空城。 */ + fun airStrikeCity(roll: Int, powerPercent: Float, versus: Int): Int { + if (versus <= 0 || powerPercent <= 0f) return 0 + return ((powerPercent / 100f * roll).toInt() * versus / 100).coerceAtLeast(1) } - /** 轟炸一座空城:跟 [cityStrike] 同一個守方基準。 */ - fun airStrikeCity(power: Float, cityDefence: Int, rng: Rng): Int { - if (power <= 0f) return 0 - val d = CITY_STRIKE_DEFENCE_BASE * (1f + cityDefence / 100f) - return jitter(DAMAGE_SCALE * power / (power + d), rng).coerceAtLeast(1) - } - - fun defencePower(ctx: CombatContext): Float { - val u = ctx.defender - var p = u.kind.defence.toFloat() - p *= 1f + 0.05f * (u.level - 1) - p *= 1f + ctx.defenderTech / 100f - p *= 1f + ctx.defenderAura / 100f - p *= commanderDefenceFactor(u) - p *= 1f + (ctx.defenderTerrainBonus + u.entrenchBonus) / 100f - // 補給只影響一半的防禦:斷補給的守軍會變脆,但不會直接被輾平。 - p *= 0.5f + 0.5f * u.supplyFactor - p *= 0.45f + 0.55f * (u.hp / ArmyUnit.MAX_HP.toFloat()) - // 砲兵被貼身時幾乎沒有自衛能力,這是它必須被保護的原因。 - if (u.kind.isBombard && ctx.distance <= 1) p *= 0.55f - // 浮渡中的陸軍防禦幾乎歸零 —— 這正是它不該單獨出海的原因。 - if (ctx.defenderAtSea) p *= 0.15f - return p.coerceAtLeast(1f) - } - - /** 只算傷害不改狀態,供 AI 與 UI 的預測面板使用。 */ - fun previewDamage(ctx: CombatContext): Int { - val a = attackPower(ctx) - if (a <= 0f) return 0 - val d = defencePower(ctx) - return (DAMAGE_SCALE * a / (a + d)).roundToInt().coerceIn(1, ArmyUnit.MAX_HP) - } + // ------------------------------------------------------------------ + // 交戰 + // ------------------------------------------------------------------ fun canReach(attacker: ArmyUnit, distance: Int): Boolean = distance >= attacker.kind.minRange.coerceAtLeast(1) && distance <= attacker.kind.maxRange /** - * 守方會不會還手。三個條件全部要成立: - * 還活著、貼身戰(遠程轟擊無法反擊)、而且守方的射程涵蓋這個距離。 + * 守方會不會還手。全部要成立: + * 還活著、貼身戰(遠程轟擊無法反擊)、守方的射程涵蓋這個距離、沒有混亂。 */ fun canRetaliate(ctx: CombatContext): Boolean { val d = ctx.defender @@ -249,93 +356,71 @@ object Combat { if (ctx.defenderAtSea) return false // 魚雷:潛艇出手時對方來不及反應。潛艇本體很脆,全靠這一條活著。 if (ctx.attacker.kind.isStealth) return false - return d.kind.attackAgainst(ctx.attacker.kind.targetClass) > 0 + return d.kind.versus(ctx.attacker.kind.targetClass) > 0 } - /** 站在城裡的部隊只吃這一部分的傷害。 */ - private fun shielded(damage: Int, inCity: Boolean): Int = - if (inCity) (damage * UNIT_DAMAGE_IN_CITY_PERCENT / 100).coerceAtLeast(1) else damage + /** 不改狀態的預測:用平均擲骰算這一擊打掉守方多少 HP。AI 與 UI 用。 */ + fun previewDamage(ctx: CombatContext): Int = + hit(ctx, meanRoll(ctx.attacker.kind), ctx.attacker.hp, canRetaliate(ctx)).toUnit - /** 城市替駐軍多擋的那一層,扣在城防上。 */ - private fun cityShare(damage: Int, inCity: Boolean): Int = - if (inCity) damage * CITY_DAMAGE_PERCENT / 100 else 0 + /** + * 不改狀態的預測:守方挨完 [dealt] 之後的反擊會打掉攻方多少。 + * 守方被打死、或根本不能還手時是 0。 + */ + fun previewRetaliation(ctx: CombatContext, dealt: Int): Int { + if (!canRetaliate(ctx)) return 0 + val left = ctx.defender.hp - dealt + if (left <= 0) return 0 + return hit(ctx.swapped(), meanRoll(ctx.defender.kind), left, true).toUnit + } + /** + * 一次交戰:攻方打一擊;守方活著而且能還手,就以挨打後的 HP 全力反擊一擊。 + * 反擊不打折 —— 參考遊戲的反擊跟主動攻擊走同一條公式。 + */ fun resolve(ctx: CombatContext, rng: Rng): CombatResult { - val a = attackPower(ctx) - val d = defencePower(ctx) - val rawDamage = if (a <= 0f) 0f else DAMAGE_SCALE * a / (a + d) - val landed = jitter(rawDamage, rng).coerceIn(if (a > 0f) 1 else 0, ArmyUnit.MAX_HP) - val toDefender = shielded(landed, ctx.defenderInCity) - val toDefenderCity = cityShare(landed, ctx.defenderInCity) + val exchange = canRetaliate(ctx) + val first = hit(ctx, roll(ctx.attacker.kind, rng), ctx.attacker.hp, exchange) val defenderBefore = ctx.defender.hp - ctx.defender.damage(toDefender) - // 回報的是實際掉了幾成而不是原始傷害 —— 大編制吃同一發會掉得比較少, - // 戰報與經驗值都該照實際的算。 + ctx.defender.damage(first.toUnit) val defenderLost = defenderBefore - ctx.defender.hp val defenderDead = !ctx.defender.isAlive - var toAttacker = 0 + var attackerLost = 0 var toAttackerCity = 0 - if (!defenderDead && canRetaliate(ctx)) { - // 反擊 = 角色互換之後的同一支公式,再乘上折扣。 - val counter = CombatContext( - attacker = ctx.defender, - defender = ctx.attacker, - attackerAtSea = ctx.defenderAtSea, - defenderAtSea = ctx.attackerAtSea, - attackerMorale = ctx.defenderMorale, - defenderMorale = ctx.attackerMorale, - defenderTerrainBonus = ctx.attackerTerrainBonus, - attackerTerrainBonus = ctx.defenderTerrainBonus, - distance = ctx.distance, - attackerTech = ctx.defenderTech, - defenderTech = ctx.attackerTech, - attackerAura = ctx.defenderAura, - defenderAura = ctx.attackerAura, - attackerInCity = ctx.defenderInCity, - defenderInCity = ctx.attackerInCity - ) - val ca = attackPower(counter) - val cd = defencePower(counter) - if (ca > 0f) { - val raw = DAMAGE_SCALE * RETALIATION_SCALE * ca / (ca + cd) - val counterLanded = jitter(raw, rng).coerceIn(1, ArmyUnit.MAX_HP) - toAttacker = shielded(counterLanded, ctx.attackerInCity) - toAttackerCity = cityShare(counterLanded, ctx.attackerInCity) - val attackerBefore = ctx.attacker.hp - ctx.attacker.damage(toAttacker) - toAttacker = attackerBefore - ctx.attacker.hp - } + if (!defenderDead && exchange) { + val counter = hit(ctx.swapped(), roll(ctx.defender.kind, rng), ctx.defender.hp, true) + toAttackerCity = counter.toCity + val attackerBefore = ctx.attacker.hp + ctx.attacker.damage(counter.toUnit) + attackerLost = attackerBefore - ctx.attacker.hp } val attackerDead = !ctx.attacker.isAlive - val attackerLevelled = ctx.attacker.gainExp(expFor(defenderLost, defenderDead)) + val attackerLevelled = ctx.attacker.gainExp(expFor(defenderLost, ctx.defender.maxHp, defenderDead)) val defenderLevelled = - if (!defenderDead) ctx.defender.gainExp(expFor(toAttacker, attackerDead)) else false + if (!defenderDead) ctx.defender.gainExp(expFor(attackerLost, ctx.attacker.maxHp, attackerDead)) else false return CombatResult( damageToDefender = defenderLost, - damageToAttacker = toAttacker, + damageToAttacker = attackerLost, defenderDestroyed = defenderDead, attackerDestroyed = attackerDead, - damageToDefenderCity = toDefenderCity, + damageToDefenderCity = first.toCity, damageToAttackerCity = toAttackerCity, attackerLevelled = attackerLevelled, defenderLevelled = defenderLevelled ) } - /** 經驗跟著實際打出的傷害走,擊毀另有一筆獎勵。 */ - private fun expFor(damage: Int, killed: Boolean): Int = - (damage / 3) + if (killed) 25 else 0 - - private fun jitter(value: Float, rng: Rng): Int { - if (value <= 0f) return 0 - val factor = 0.92f + rng.nextFloat() * 0.16f - return (value * factor).roundToInt() + /** 經驗跟著打掉對方幾成 HP 走,擊毀另有一筆獎勵。本專案的規則。 */ + private fun expFor(damage: Int, victimMaxHp: Int, killed: Boolean): Int { + val percent = if (victimMaxHp <= 0) 0 else damage * 100 / victimMaxHp + return percent / 3 + if (killed) 25 else 0 } + /** 攻方指揮官的傷害倍率。本專案的規則,位置相當於參考遊戲的將領技能倍率。 */ private fun commanderAttackFactor(unit: ArmyUnit, target: ArmyUnit): Float { val cmd = unit.commander ?: return 1f var f = 1f @@ -343,17 +428,8 @@ object Combat { if (cmd.has(CommanderSkill.ARMOUR_EXPERT) && unit.kind.branch == TechBranch.ARMOUR) f *= 1.20f if (cmd.has(CommanderSkill.NAVAL_EXPERT) && unit.kind.branch == TechBranch.NAVY) f *= 1.20f if (cmd.has(CommanderSkill.ARTILLERY_EXPERT) && unit.kind.branch == TechBranch.ARTILLERY) f *= 1.20f - // 攻城:對方縮在城裡才有加成,由呼叫端把城市加成寫進 entrenchment 以外的地方判斷不了, - // 因此改看目標的築壕值 —— 守得越久,攻城專家的價值越高。 + // 攻城專家看目標的築壕值 —— 守得越久,攻城專家的價值越高。 if (cmd.has(CommanderSkill.SIEGE) && target.entrenchment > 0) f *= 1.25f return f } - - private fun commanderDefenceFactor(unit: ArmyUnit): Float { - val cmd = unit.commander ?: return 1f - var f = 1f - if (cmd.has(CommanderSkill.DEFENSIVE)) f *= 1.15f - if (cmd.has(CommanderSkill.FORTRESS) && unit.entrenchment >= 2) f *= 1.20f - return f - } } diff --git a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/units/UnitKind.kt b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/units/UnitKind.kt index 709b96f..a0a08a0 100644 --- a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/units/UnitKind.kt +++ b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/units/UnitKind.kt @@ -3,7 +3,6 @@ package io.github.acidefluorhydrique.mapconquer.units -/** 單位活動的層面。三層互不重疊,同一格可以同時站一支陸軍與一架飛機。 */ /** * 部隊走在哪裡。 * @@ -37,15 +36,26 @@ enum class TechBranch { INFANTRY, ARMOUR, ARTILLERY, AIR, NAVY, LOGISTICS } * 所有數值都是常數:本作沒有隨機生成的單位,一支步兵在任何劇本裡都是同一支步兵, * 差別只在等級、補給、指揮官與科技。這讓玩家在戰役學到的東西可以直接帶進征服模式。 * - * [attack] 的四個值依序對應 [TargetClass] 的四個成員。 + * 攻擊、生命、防禦的數量級對齊參考遊戲(見 docs/original-behavior.md 第 6 節): + * 一次攻擊在 [attackMin]..[attackMax] 之間擲一個整數,防禦是個位數到二十幾的小數字, + * 每 1 點防禦把傷害的分母加大 1.6%。 + * + * [versus] 是本專案自己的兵種相剋:對四類目標各一個百分比,0 代表打不到。 + * 參考遊戲用兵種特性做同樣的事(反坦克打裝甲有加成、艦炮打陸地減半), + * 本專案把它攤平成一張表,玩家看面板就懂。四個值依序對應 [TargetClass]。 */ enum class UnitKind( val key: String, val domain: Domain, val targetClass: TargetClass, val branch: TechBranch, - /** 對 SOFT / ARMOURED / SHIP / AIRCRAFT 的攻擊力。 */ - val attack: IntArray, + /** 一次攻擊擲骰的下限與上限(含)。 */ + val attackMin: Int, + val attackMax: Int, + /** 對 SOFT / ARMOURED / SHIP / AIRCRAFT 的效果百分比。 */ + val versus: IntArray, + /** 一個編制、一級時的最大生命。 */ + val hp: Int, val defence: Int, val movement: Int, /** 射程下限。> 1 代表這是砲兵,只能遠射、不能近戰。 */ @@ -68,27 +78,27 @@ enum class UnitKind( // ---- 陸軍 ---- INFANTRY( "unit_infantry", Domain.LAND, TargetClass.SOFT, TechBranch.INFANTRY, - intArrayOf(22, 10, 6, 8), 26, 4, 1, 1, 2, + 16, 22, intArrayOf(100, 50, 30, 40), 80, 1, 4, 1, 1, 2, 90, 0, 0, true, false, 4, 0 ), MOUNTAIN_INFANTRY( "unit_mountain_infantry", Domain.LAND, TargetClass.SOFT, TechBranch.INFANTRY, - intArrayOf(24, 12, 6, 8), 28, 4, 1, 1, 3, + 18, 24, intArrayOf(100, 50, 25, 35), 90, 2, 4, 1, 1, 3, 130, 1, 0, true, false, 4, Flags.MOUNTAINEER ), MARINE( "unit_marine", Domain.LAND, TargetClass.SOFT, TechBranch.INFANTRY, - intArrayOf(26, 14, 10, 8), 26, 4, 1, 1, 2, + 18, 26, intArrayOf(100, 55, 40, 30), 90, 2, 4, 1, 1, 2, 150, 1, 0, true, false, 5, Flags.AMPHIBIOUS ), RECON( "unit_recon", Domain.LAND, TargetClass.ARMOURED, TechBranch.ARMOUR, - intArrayOf(24, 14, 6, 8), 22, 9, 1, 1, 5, + 20, 34, intArrayOf(100, 55, 25, 30), 120, 6, 9, 1, 1, 5, 140, 1, 0, true, true, 5, Flags.RECON or Flags.ASSAULT ), ARMOUR( "unit_armour", Domain.LAND, TargetClass.ARMOURED, TechBranch.ARMOUR, - intArrayOf(40, 32, 10, 6), 38, 7, 1, 1, 3, + 28, 44, intArrayOf(100, 80, 25, 15), 180, 10, 7, 1, 1, 3, 240, 2, 0, true, true, 8, Flags.BREAKTHROUGH or Flags.ASSAULT ), /** @@ -101,72 +111,71 @@ enum class UnitKind( */ ANTI_TANK( "unit_anti_tank", Domain.LAND, TargetClass.SOFT, TechBranch.ARTILLERY, - intArrayOf(8, 50, 8, 4), 24, 3, 1, 1, 2, + 26, 38, intArrayOf(20, 100, 20, 10), 90, 4, 3, 1, 1, 2, 160, 1, 0, false, true, 5, 0 ), ARTILLERY( "unit_artillery", Domain.LAND, TargetClass.SOFT, TechBranch.ARTILLERY, - intArrayOf(38, 26, 20, 0), 14, 3, 2, 3, 2, + 32, 46, intArrayOf(100, 70, 50, 0), 100, 5, 3, 2, 3, 2, 190, 1, 0, false, true, 6, Flags.BOMBARD ), ROCKET( "unit_rocket", Domain.LAND, TargetClass.SOFT, TechBranch.ARTILLERY, - intArrayOf(46, 28, 20, 0), 12, 3, 2, 4, 2, + 34, 50, intArrayOf(100, 60, 45, 0), 95, 5, 3, 2, 4, 2, 260, 2, 0, false, true, 8, Flags.BOMBARD ), ANTI_AIR( "unit_anti_air", Domain.LAND, TargetClass.ARMOURED, TechBranch.ARTILLERY, - intArrayOf(12, 10, 6, 52), 20, 4, 1, 2, 3, + 18, 26, intArrayOf(55, 45, 25, 220), 100, 6, 4, 1, 2, 3, 150, 1, 0, false, true, 5, Flags.INTERCEPT ), SUPPLY_TRUCK( "unit_supply_truck", Domain.LAND, TargetClass.SOFT, TechBranch.LOGISTICS, - intArrayOf(0, 0, 0, 0), 10, 6, 0, 0, 2, + 0, 0, intArrayOf(0, 0, 0, 0), 80, 2, 6, 0, 0, 2, 110, 1, 1, false, true, 3, Flags.SUPPLIER ), HEADQUARTERS( "unit_headquarters", Domain.LAND, TargetClass.SOFT, TechBranch.LOGISTICS, - intArrayOf(8, 6, 0, 4), 18, 5, 1, 1, 4, + 10, 16, intArrayOf(100, 70, 0, 50), 120, 4, 5, 1, 1, 4, 300, 2, 1, true, true, 6, Flags.SUPPLIER or Flags.COMMAND_AURA ), // ---- 海軍 ---- TRANSPORT_SHIP( "unit_transport_ship", Domain.SEA, TargetClass.SHIP, TechBranch.NAVY, - intArrayOf(0, 0, 0, 0), 16, 7, 0, 0, 3, + 0, 0, intArrayOf(0, 0, 0, 0), 120, 4, 7, 0, 0, 3, 150, 1, 3, false, false, 4, 0 ), DESTROYER( "unit_destroyer", Domain.SEA, TargetClass.SHIP, TechBranch.NAVY, - intArrayOf(20, 16, 40, 30), 30, 8, 1, 1, 5, + 32, 52, intArrayOf(50, 40, 100, 70), 260, 12, 8, 1, 1, 5, 220, 2, 0, false, false, 6, Flags.SUB_HUNTER or Flags.INTERCEPT ), CRUISER( "unit_cruiser", Domain.SEA, TargetClass.SHIP, TechBranch.NAVY, - intArrayOf(30, 24, 46, 40), 40, 7, 1, 2, 4, + 40, 60, intArrayOf(60, 50, 100, 80), 380, 18, 7, 1, 2, 4, 320, 3, 0, false, false, 8, Flags.INTERCEPT ), /** * 戰艦的射程 1..3 已經給了它岸轟能力:[Combat.canRetaliate] 只看距離, * 隔著兩格開火本來就不會被還手。 * - * 它刻意**沒有** [Flags.BOMBARD] —— 那個旗標的意思是「被貼身就毫無自衛能力」 - * (守方防禦乘 0.55),適用於火炮與火箭炮那種薄皮兵器。 - * 全場最高的 55 防禦不該吃這個懲罰,不然戰艦會變成怕驅逐艦的怪東西。 + * 它刻意**沒有** [Flags.BOMBARD] —— 那個旗標的意思是「只能遠射、被貼身時還不了手」, + * 適用於火炮與火箭炮那種薄皮兵器。戰艦貼身照樣開火。 */ BATTLESHIP( "unit_battleship", Domain.SEA, TargetClass.SHIP, TechBranch.NAVY, - intArrayOf(54, 46, 60, 20), 55, 6, 1, 3, 4, + 56, 80, intArrayOf(90, 75, 100, 30), 600, 24, 6, 1, 3, 4, 480, 4, 0, false, false, 11, 0 ), SUBMARINE( "unit_submarine", Domain.SEA, TargetClass.SHIP, TechBranch.NAVY, - intArrayOf(6, 6, 62, 0), 18, 7, 1, 1, 3, + 28, 48, intArrayOf(10, 10, 100, 0), 180, 6, 7, 1, 1, 3, 280, 3, 0, false, false, 7, Flags.STEALTH ), CARRIER( "unit_carrier", Domain.SEA, TargetClass.SHIP, TechBranch.NAVY, - intArrayOf(10, 8, 14, 26), 42, 7, 1, 1, 5, + 16, 26, intArrayOf(60, 50, 70, 125), 300, 18, 7, 1, 1, 5, 520, 4, 0, false, false, 10, Flags.AIRBASE ); @@ -188,12 +197,19 @@ enum class UnitKind( /** 殲滅目標後可以再打一次。這是裝甲之所以能決定戰局的原因。 */ val isAssault: Boolean get() = flags and Flags.ASSAULT != 0 - val canAttack: Boolean get() = attack.any { it > 0 } + val canAttack: Boolean get() = attackMax > 0 && versus.any { it > 0 } /** 只能停在海上,且需要臨海城市才造得出來。 */ val isNaval: Boolean get() = domain == Domain.SEA - fun attackAgainst(target: TargetClass): Int = attack[target.ordinal] + /** 對這類目標的效果百分比;0 代表打不到。 */ + fun versus(target: TargetClass): Int = if (attackMax > 0) versus[target.ordinal] else 0 + + /** 平均一擊的攻擊值(未計任何修正),面板與防空估算用。 */ + val averageAttack: Int get() = (attackMin + attackMax) / 2 + + /** 對這類目標的平均攻擊值,給玩家看的那個數字。 */ + fun attackAgainst(target: TargetClass): Int = averageAttack * versus(target) / 100 /** 這個兵種載得動什麼。只剩運輸艦載陸軍;航艦現在是空中任務的起飛點。 */ fun canCarry(other: UnitKind): Boolean = when { @@ -223,7 +239,7 @@ enum class UnitKind( /** 兵種旗標。用位元而不是一堆 Boolean 欄位,純粹是為了讓上面的表讀得完。 */ object Flags { /** - * 只能遠射,被貼身時防禦會大打折扣。 + * 只能遠射,被貼身時還不了手。 * 帶這個旗標的兵種 minRange 必須 > 1 —— 「射程涵蓋一格」與「近戰無力」 * 是互相矛盾的兩件事,CombatTest 會把這條不變量釘住。 */ diff --git a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/world/Province.kt b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/world/Province.kt index 5804d20..acee817 100644 --- a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/world/Province.kt +++ b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/world/Province.kt @@ -38,30 +38,20 @@ class Province( else -> 40 } - /** 城市自帶的守備加成百分比。 */ - val cityDefenceBonus: Int - get() = when (cityTier) { - 0 -> 0 - 1 -> 15 - 2 -> 25 - 3 -> 35 - else -> 45 - } - /** * 城防上限。 * * 城市不是一塊走過去就能拿走的地磚:它要先被打垮。城防同時是駐軍的 - * 護盾(見 Combat 的分攤規則)與易主的門檻,所以級距拉得比收入更陡 —— - * 一座首都級的大城應該要花好幾回合才啃得下來。 + * 護盾(見 Combat 的分攤規則)與易主的門檻。數值取參考遊戲的小城市、中城市、 + * 大城市、大都市:150 / 300 / 450 / 600。城防本身沒有防禦,一擊打掉的就是攻擊值。 */ val maxCityHp: Int get() = when (cityTier) { 0 -> 0 - 1 -> 40 - 2 -> 70 - 3 -> 110 - else -> 160 + 1 -> 150 + 2 -> 300 + 3 -> 450 + else -> 600 } /** 每回合可產出的工業點數上限:能不能造重裝備看這個。 */ diff --git a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/world/Terrain.kt b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/world/Terrain.kt index e018eec..bb845e3 100644 --- a/app/src/main/java/io/github/acidefluorhydrique/mapconquer/world/Terrain.kt +++ b/app/src/main/java/io/github/acidefluorhydrique/mapconquer/world/Terrain.kt @@ -6,9 +6,11 @@ package io.github.acidefluorhydrique.mapconquer.world /** * 地形。 * - * 每種地形只帶三個真正影響玩法的數字:陸上移動成本、防禦加成、以及能不能通行。 - * 刻意不做「地形 × 兵種」的大表 —— 兵種的差異放在 [io.github.acidefluorhydrique.mapconquer.units.UnitKind] - * 的 domain 與 mountaineer 這類旗標上,兩邊相乘就夠出戰術,卻不會變成沒人看得懂的平衡黑箱。 + * 每種地形帶的玩法數字:陸上移動成本、兩個攻擊懲罰、以及能不能通行。 + * + * 地形不替守方加防禦。跟參考遊戲一樣,它只懲罰**攻方**的裝甲與火炮: + * 往森林裡開炮、把戰車開上山去打人,傷害會打折;步兵與軍艦不受影響。 + * 所以守山的是步兵,平原才是戰車的地方。 * * [code] 是地圖檔裡的單一字元。改動它等於改動 assets/maps 底下的每一張地圖,別亂動。 */ @@ -17,8 +19,10 @@ enum class Terrain( val key: String, /** 陸軍進入此格要花的移動點;水域為 0(陸軍根本進不去)。 */ val moveCost: Int, - /** 守方防禦加成百分比。 */ - val defenceBonus: Int, + /** 攻方是裝甲時,打進這一格的傷害少幾 %。 */ + val armourPenalty: Int, + /** 攻方是火炮時,打進這一格的傷害少幾 %。 */ + val artilleryPenalty: Int, val isWater: Boolean, /** 深水:登陸艇與淺水單位進不來,主力艦隊的活動空間。 */ val isDeep: Boolean, @@ -32,22 +36,29 @@ enum class Terrain( /** 每回合的基礎產值,城市會在這之上再加。 */ val income: Int ) { - OCEAN('~', "terrain_ocean", 0, 0, true, true, false, "#16354F", 0), - SEA('-', "terrain_sea", 0, 0, true, false, false, "#1E4F70", 0), - PLAIN('.', "terrain_plain", 1, 0, false, false, true, "#4E6B41", 2), - FARMLAND(':', "terrain_farmland", 1, 0, false, false, true, "#6E7F3E", 4), - FOREST('f', "terrain_forest", 2, 20, false, false, true, "#33512F", 2), - JUNGLE('j', "terrain_jungle", 3, 25, false, false, false, "#2C5732", 1), - HILLS('h', "terrain_hills", 2, 25, false, false, true, "#6A6238", 2), - MOUNTAIN('^', "terrain_mountain", 4, 45, false, false, false, "#7A6B57", 1), - DESERT('d', "terrain_desert", 1, -5, false, false, true, "#A08A55", 1), - SWAMP('s', "terrain_swamp", 3, 10, false, false, false, "#40573F", 1), - TUNDRA('t', "terrain_tundra", 2, 5, false, false, true, "#6B7461", 1), - ICE('*', "terrain_ice", 3, 5, false, false, false, "#B7C6CE", 0), - RIVER('r', "terrain_river", 3, 15, false, false, true, "#3C6272", 3); + OCEAN('~', "terrain_ocean", 0, 0, 0, true, true, false, "#16354F", 0), + SEA('-', "terrain_sea", 0, 0, 0, true, false, false, "#1E4F70", 0), + PLAIN('.', "terrain_plain", 1, 0, 0, false, false, true, "#4E6B41", 2), + FARMLAND(':', "terrain_farmland", 1, 0, 0, false, false, true, "#6E7F3E", 4), + FOREST('f', "terrain_forest", 2, 5, 10, false, false, true, "#33512F", 2), + JUNGLE('j', "terrain_jungle", 3, 10, 20, false, false, false, "#2C5732", 1), + HILLS('h', "terrain_hills", 2, 10, 5, false, false, true, "#6A6238", 2), + MOUNTAIN('^', "terrain_mountain", 4, 20, 10, false, false, false, "#7A6B57", 1), + DESERT('d', "terrain_desert", 1, 10, 10, false, false, true, "#A08A55", 1), + SWAMP('s', "terrain_swamp", 3, 10, 10, false, false, false, "#40573F", 1), + TUNDRA('t', "terrain_tundra", 2, 0, 0, false, false, true, "#6B7461", 1), + ICE('*', "terrain_ice", 3, 5, 5, false, false, false, "#B7C6CE", 0), + RIVER('r', "terrain_river", 3, 0, 0, false, false, true, "#3C6272", 3); val isLand: Boolean get() = !isWater + /** + * 參考遊戲的地形表:沙漠 10/10、丘陵 10/5、山 20/10、樹林 5/10、森林 10/20 + * (裝甲/火炮),平原、農田、河流沒有懲罰。本專案的森林對應樹林、叢林對應森林; + * 沼澤、凍原、冰原是參考遊戲沒有的地形,數值是本專案自訂的。 + */ + val hasAttackPenalty: Boolean get() = armourPenalty > 0 || artilleryPenalty > 0 + /** 供補給線與登陸判定使用:淺海緊貼陸地。 */ val isShallow: Boolean get() = isWater && !isDeep diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 0e0382e..9fc4a35 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -62,14 +62,14 @@ %1$s:城防 −%2$d %1$s 的城防被打掉 %2$d 城防 %1$d/%2$d - %1$s · 防御 +%2$d%% + %1$s · 装甲 −%2$d%% · 火炮 −%3$d%% 等级 %1$d · 移动 %2$d/%3$d 筑壕 %1$d 级 %1$s 行动中… 生产 编制 %1$s · 工业 %2$d · 资金 %3$d - 攻 %1$d 防 %2$d 动 %3$d + 攻 %1$d–%2$d 防 %3$d 血 %4$d 动 %5$d 研发 战役目标 各国局势 @@ -97,7 +97,7 @@ 对舰艇 对空 等级 %1$d · 经验 %2$d - 防御 %1$d · 移动 %2$d · 射程 %3$d-%4$d · 视野 %5$d + 血 %1$d/%2$d · 防御 %3$d · 移动 %4$d · 射程 %5$d-%6$d · 视野 %7$d 指派指挥官 勋章 %1$d 招募 %1$d @@ -171,7 +171,7 @@ 战斗 士气与包围 士气不是会慢慢流失的一条槽,而是「这支部队现在站在什么处境」的读数。对向两格有敌人就是夹击、士气下降;四面以上有敌人就是包围、士气严重下降;再降一级就是混乱 —— 无法攻击,也无法还手。带谣言的指挥官每次命中都有机会再压一级,所以包围加一次谣言、夹击加两次、或是纯粹三次谣言,结果都一样。正因为它读的是处境,包围圈一松开,士气就立刻回来。 - 每支部队都有四个独立的攻击值:对步兵、对装甲、对舰艇、对空(对空就是防空火力)。反坦克炮打战车势如破竹,打步兵却软弱无力。火炮从两格以外开火且不会被反击,可是一旦被贴身就几乎没有自卫能力 —— 前面一定要有步兵挡着。地形、城墙与筑壕都会提高守方的防御,所以「打山里挖好壕沟的敌人」跟「在农地上逮到它」是两回事。 + 每次攻击在部队的最小与最大攻击之间掷一个数,目标的每一点防御都会把伤害削掉一些 —— 但永远削不完。部队对步兵、装甲、舰艇、飞机各有不同的效果:反坦克炮打战车势如破竹,打步兵却软弱无力。HP 不到一半的部队攻击减半;挨打后还活着的守方一定会全力反击。火炮从两格以外开火且不会被反击,可是一旦被贴身就还不了手 —— 前面一定要有步兵挡着。地形不会替守方挡伤害,而是让打进去的战车与火炮打折(丘陵、山地、森林、丛林、沙漠),所以守高地的是步兵。筑壕与城墙会减少守方挨的伤害。 士气高昂 正常 士气下降 diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index e955144..67b0965 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -62,14 +62,14 @@ %1$s:城防 −%2$d %1$s 的城防被打掉 %2$d 城防 %1$d/%2$d - %1$s · 防禦 +%2$d%% + %1$s · 裝甲 −%2$d%% · 火炮 −%3$d%% 等級 %1$d · 移動 %2$d/%3$d 築壕 %1$d 級 %1$s 行動中… 生產 編制 %1$s · 工業 %2$d · 資金 %3$d - 攻 %1$d 防 %2$d 動 %3$d + 攻 %1$d–%2$d 防 %3$d 血 %4$d 動 %5$d 研發 戰役目標 各國局勢 @@ -97,7 +97,7 @@ 對艦艇 對空 等級 %1$d · 經驗 %2$d - 防禦 %1$d · 移動 %2$d · 射程 %3$d-%4$d · 視野 %5$d + 血 %1$d/%2$d · 防禦 %3$d · 移動 %4$d · 射程 %5$d-%6$d · 視野 %7$d 指派指揮官 勳章 %1$d 招募 %1$d @@ -171,7 +171,7 @@ 戰鬥 士氣與包圍 士氣不是會慢慢流失的一條槽,而是「這支部隊現在站在什麼處境」的讀數。對向兩格有敵人就是夾擊、士氣下降;四面以上有敵人就是包圍、士氣嚴重下降;再降一級就是混亂 —— 無法攻擊,也無法還手。帶謠言的指揮官每次命中都有機會再壓一級,所以包圍加一次謠言、夾擊加兩次、或是純粹三次謠言,結果都一樣。正因為它讀的是處境,包圍圈一鬆開,士氣就立刻回來。 - 每支部隊都有四個獨立的攻擊值:對步兵、對裝甲、對艦艇、對空(對空就是防空火力)。反坦克炮打戰車勢如破竹,打步兵卻軟弱無力。火炮從兩格以外開火且不會被反擊,可是一旦被貼身就幾乎沒有自衛能力 —— 前面一定要有步兵擋著。地形、城牆與築壕都會提高守方的防禦,所以「打山裡挖好壕溝的敵人」跟「在農地上逮到它」是兩回事。 + 每次攻擊在部隊的最小與最大攻擊之間擲一個數,目標的每一點防禦都會把傷害削掉一些 —— 但永遠削不完。部隊對步兵、裝甲、艦艇、飛機各有不同的效果:反坦克炮打戰車勢如破竹,打步兵卻軟弱無力。HP 不到一半的部隊攻擊減半;挨打後還活著的守方一定會全力反擊。火炮從兩格以外開火且不會被反擊,可是一旦被貼身就還不了手 —— 前面一定要有步兵擋著。地形不會替守方擋傷害,而是讓打進去的戰車與火炮打折(丘陵、山地、森林、叢林、沙漠),所以守高地的是步兵。築壕與城牆會減少守方挨的傷害。 士氣高昂 正常 士氣下降 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 44262a7..2521615 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,14 +62,14 @@ %1$s: city defence −%2$d %1$s took %2$d damage to its defences City defence %1$d/%2$d - %1$s · defence +%2$d%% + %1$s · armour −%2$d%% · artillery −%3$d%% Level %1$d · moves %2$d/%3$d Entrenched %1$d %1$s is moving… Production Formation %1$s · industry %2$d · funds %3$d - ATK %1$d DEF %2$d MOV %3$d + ATK %1$d–%2$d DEF %3$d HP %4$d MOV %5$d Research Objectives Standings @@ -97,7 +97,7 @@ vs Ships vs Aircraft Level %1$d · %2$d XP - DEF %1$d · MOV %2$d · range %3$d-%4$d · vision %5$d + HP %1$d/%2$d · DEF %3$d · MOV %4$d · range %5$d-%6$d · vision %7$d Assign commander Medals %1$d Recruit %1$d @@ -171,7 +171,7 @@ Combat Morale and encirclement Morale is not a bar that drains -- it is a read of the position a unit is in right now. Enemies on two opposite sides leave it shaken; four or more around it leave it broken; one step further and it is disrupted, unable to attack or even return fire. A commander with Rumour pushes the target one step lower on a hit, so encircle plus one rumour, or a flank plus two, or three rumours alone all reach the same place. Because morale reads the position, it comes straight back the moment the ring opens up. - Every unit has four separate attack values: against infantry, armour, ships, and anti-air fire. Anti-tank guns shred armour and bounce off infantry. Artillery strikes from two or more hexes away and takes no return fire, but is nearly helpless once something reaches it — keep infantry in front of it. Terrain, city walls and entrenchment all raise the defender\'s strength, so attacking a dug-in unit in the mountains is a very different proposition from catching it on open farmland. + Every attack rolls a number between the unit\'s minimum and maximum attack, and each point of the target\'s defence trims a little off the hit — never all of it. Units have separate strengths against infantry, armour, ships and aircraft: anti-tank guns shred armour and bounce off infantry. A unit below half its HP hits at half strength, and a defender that survives always fires back at full strength. Artillery strikes from two or more hexes away and takes no return fire, but cannot fire back once something reaches it — keep infantry in front of it. Terrain does not shield the defender; it hampers tanks and guns attacking into it (hills, mountains, forest, jungle, desert), which is why infantry holds the high ground. Entrenchment and city walls cut the damage a defender takes. Elevated Steady Shaken diff --git a/app/src/test/java/io/github/acidefluorhydrique/mapconquer/game/ConquestSmokeTest.kt b/app/src/test/java/io/github/acidefluorhydrique/mapconquer/game/ConquestSmokeTest.kt index c6209e0..202f672 100644 --- a/app/src/test/java/io/github/acidefluorhydrique/mapconquer/game/ConquestSmokeTest.kt +++ b/app/src/test/java/io/github/acidefluorhydrique/mapconquer/game/ConquestSmokeTest.kt @@ -28,7 +28,7 @@ class ConquestSmokeTest { for (unit in session.units) { assertTrue("$where: 死掉的部隊還留在場上", unit.isAlive) assertTrue("$where: 座標越界 ${unit.tile}", unit.tile in 0 until map.tileCount) - assertTrue("$where: hp 越界 ${unit.hp}", unit.hp in 1..ArmyUnit.MAX_HP) + assertTrue("$where: hp 越界 ${unit.hp}", unit.hp in 1..unit.maxHp) assertTrue("$where: 補給越界 ${unit.supply}", unit.supply in 0..ArmyUnit.MAX_SUPPLY) assertTrue("$where: 等級越界 ${unit.level}", unit.level in 1..ArmyUnit.MAX_LEVEL) assertTrue("$where: 國家越界", unit.nationId in session.nations.indices) diff --git a/app/src/test/java/io/github/acidefluorhydrique/mapconquer/game/SessionTest.kt b/app/src/test/java/io/github/acidefluorhydrique/mapconquer/game/SessionTest.kt index 0c25f8f..4b7dbcc 100644 --- a/app/src/test/java/io/github/acidefluorhydrique/mapconquer/game/SessionTest.kt +++ b/app/src/test/java/io/github/acidefluorhydrique/mapconquer/game/SessionTest.kt @@ -359,7 +359,7 @@ class SessionTest { val result = AirOps.fly(s, 0, AirMission.FIGHTER, target.tile) assertNotNull(result) assertTrue("要打出傷害", result!!.damageToUnit > 0) - assertTrue(target.hp < ArmyUnit.MAX_HP) + assertTrue(target.hp < target.maxHp) assertEquals(1_000 - AirMission.FIGHTER.totalCost, nation.funds) assertEquals("飛機不會留在地圖上", 1, s.units.size) @@ -441,15 +441,16 @@ class SessionTest { fun `a formation is stronger than one unit but weaker than the units it absorbed`() { for (n in 2..ArmyUnit.MAX_SIZE) { assertTrue(ArmyUnit.attackPercent(n) > ArmyUnit.attackPercent(n - 1)) - assertTrue(ArmyUnit.hpPercent(n) > ArmyUnit.hpPercent(n - 1)) + assertTrue(ArmyUnit.sizeHpPercent(n) > ArmyUnit.sizeHpPercent(n - 1)) assertTrue("攻擊不該追上 $n 支分開的部隊", ArmyUnit.attackPercent(n) < 100 * n) - assertTrue("耐打不該追上 $n 支分開的部隊", ArmyUnit.hpPercent(n) < 100 * n) + assertTrue("生命不該追上 $n 支分開的部隊", ArmyUnit.sizeHpPercent(n) < 100 * n) } val small = ArmyUnit(1, UnitKind.INFANTRY, 0, 0) val big = ArmyUnit(2, UnitKind.INFANTRY, 0, 0).apply { size = 3 } + assertEquals("三編制的生命是 210%", small.maxHp * 210 / 100, big.maxHp) small.damage(30) big.damage(30) - assertTrue("同一發傷害,大編制掉的比例要比較少", big.hp > small.hp) + assertTrue("同一發傷害,大編制掉的比例要比較少", big.hpRatio > small.hpRatio) } @Test @@ -566,7 +567,7 @@ class SessionTest { val result = Orders.attack(s, attacker, defender.tile) assertNotNull(result) - assertTrue("守方應該受傷", defender.hp < ArmyUnit.MAX_HP) + assertTrue("守方應該受傷", defender.hp < defender.maxHp) assertTrue("步兵對步兵應該有反擊", result!!.damageToAttacker > 0) assertTrue(attacker.hasAttacked) assertEquals("開火之後不能再動", 0, attacker.movesLeft) @@ -928,7 +929,7 @@ class SessionTest { repeat(20) { repeat(s.nations.size) { s.advanceToNextNation() } } assertEquals("補給滑到八成戰力的門檻就停住", ArmyUnit.SUPPLY_CRITICAL, ship.supply) - assertEquals("海上不會餓死", ArmyUnit.MAX_HP, ship.hp) + assertEquals("海上不會餓死", ship.maxHp, ship.hp) assertTrue(ship.isAlive) } @@ -940,7 +941,7 @@ class SessionTest { repeat(6) { repeat(s.nations.size) { s.advanceToNextNation() } } assertEquals("陸軍的補給會見底", 0, soldier.supply) - assertTrue("見底之後開始失血", soldier.hp < ArmyUnit.MAX_HP) + assertTrue("見底之後開始失血", soldier.hp < soldier.maxHp) } @Test diff --git a/app/src/test/java/io/github/acidefluorhydrique/mapconquer/units/CombatTest.kt b/app/src/test/java/io/github/acidefluorhydrique/mapconquer/units/CombatTest.kt index 4600664..f762be0 100644 --- a/app/src/test/java/io/github/acidefluorhydrique/mapconquer/units/CombatTest.kt +++ b/app/src/test/java/io/github/acidefluorhydrique/mapconquer/units/CombatTest.kt @@ -4,6 +4,7 @@ package io.github.acidefluorhydrique.mapconquer.units import io.github.acidefluorhydrique.mapconquer.core.Rng +import io.github.acidefluorhydrique.mapconquer.world.Terrain import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -13,10 +14,11 @@ class CombatTest { private var nextId = 1 - private fun unit(kind: UnitKind, nation: Int = 0, hp: Int = 100, level: Int = 1): ArmyUnit { + /** [hp] 省略時是滿血。等級會按比例縮放 HP,所以先設等級再設 HP。 */ + private fun unit(kind: UnitKind, nation: Int = 0, hp: Int = -1, level: Int = 1): ArmyUnit { val u = ArmyUnit(nextId++, kind, nation, 0) - u.hp = hp u.level = level + if (hp >= 0) u.hp = hp return u } @@ -24,22 +26,23 @@ class CombatTest { attacker: ArmyUnit, defender: ArmyUnit, distance: Int = 1, - defenderTerrain: Int = 0, + defenderTerrain: Terrain = Terrain.PLAIN, + defenderInCity: Boolean = false, attackerAtSea: Boolean = false, defenderAtSea: Boolean = false, attackerMorale: Int = 0, defenderMorale: Int = 0 ) = CombatContext( attacker, defender, - defenderTerrainBonus = defenderTerrain, - attackerTerrainBonus = 0, + defenderTerrain = defenderTerrain, distance = distance, attackerTech = 0, defenderTech = 0, attackerAura = 0, defenderAura = 0, attackerAtSea = attackerAtSea, defenderAtSea = defenderAtSea, attackerMorale = attackerMorale, - defenderMorale = defenderMorale + defenderMorale = defenderMorale, + defenderInCity = defenderInCity ) @Test @@ -47,12 +50,15 @@ class CombatTest { val rng = Rng(42) for (attackerKind in UnitKind.ALL) { for (defenderKind in UnitKind.ALL) { - val ctx = context(unit(attackerKind), unit(defenderKind, nation = 1)) + val attacker = unit(attackerKind) + val defender = unit(defenderKind, nation = 1) + val ctx = context(attacker, defender) val preview = Combat.previewDamage(ctx) - assertTrue("$attackerKind -> $defenderKind = $preview", preview in 0..ArmyUnit.MAX_HP) + assertTrue("$attackerKind -> $defenderKind = $preview", preview in 0..defender.maxHp) val result = Combat.resolve(ctx, rng) - assertTrue(result.damageToDefender in 0..ArmyUnit.MAX_HP) - assertTrue(result.damageToAttacker in 0..ArmyUnit.MAX_HP) + assertTrue(result.damageToDefender in 0..defender.maxHp) + assertTrue(result.damageToAttacker in 0..attacker.maxHp) + assertTrue(defender.hp in 0..defender.maxHp && attacker.hp in 0..attacker.maxHp) } } } @@ -80,15 +86,21 @@ class CombatTest { } @Test - fun `terrain and entrenchment protect the defender`() { + fun `terrain hampers tanks and guns attacking into it, not infantry`() { + fun hit(attacker: UnitKind, terrain: Terrain, distance: Int = 1) = Combat.previewDamage( + context(unit(attacker), unit(UnitKind.INFANTRY, nation = 1), distance = distance, defenderTerrain = terrain) + ) + assertTrue(hit(UnitKind.ARMOUR, Terrain.MOUNTAIN) < hit(UnitKind.ARMOUR, Terrain.PLAIN)) + assertTrue(hit(UnitKind.ARTILLERY, Terrain.JUNGLE, 2) < hit(UnitKind.ARTILLERY, Terrain.PLAIN, 2)) + assertEquals("步兵不受地形影響", hit(UnitKind.INFANTRY, Terrain.PLAIN), hit(UnitKind.INFANTRY, Terrain.MOUNTAIN)) + assertEquals("軍艦不受地形影響", hit(UnitKind.DESTROYER, Terrain.PLAIN), hit(UnitKind.DESTROYER, Terrain.FOREST)) + } + + @Test + fun `entrenchment protects the defender`() { val open = Combat.previewDamage( context(unit(UnitKind.INFANTRY), unit(UnitKind.INFANTRY, nation = 1)) ) - val inMountains = Combat.previewDamage( - context(unit(UnitKind.INFANTRY), unit(UnitKind.INFANTRY, nation = 1), defenderTerrain = 45) - ) - assertTrue("$open should exceed $inMountains", open > inMountains) - val dug = unit(UnitKind.INFANTRY, nation = 1).apply { entrenchment = 3 } val versusDug = Combat.previewDamage(context(unit(UnitKind.INFANTRY), dug)) assertTrue("$open should exceed $versusDug", open > versusDug) @@ -108,15 +120,11 @@ class CombatTest { } @Test - fun `artillery caught in melee defends badly`() { - val versusGun = Combat.previewDamage( - context(unit(UnitKind.INFANTRY), unit(UnitKind.ARTILLERY, nation = 1)) - ) - val versusInfantry = Combat.previewDamage( - context(unit(UnitKind.INFANTRY), unit(UnitKind.INFANTRY, nation = 1)) - ) - // 被貼身的砲兵要明顯比一般步兵更好打,否則「保護砲兵」就不是個真的決策。 - assertTrue("gun $versusGun vs infantry $versusInfantry", versusGun > versusInfantry * 3 / 2) + fun `artillery caught in melee cannot fire back`() { + // 被貼身的砲兵還不了手,所以「保護砲兵」是個真的決策。 + val ctx = context(unit(UnitKind.INFANTRY), unit(UnitKind.ARTILLERY, nation = 1)) + assertFalse(Combat.canRetaliate(ctx)) + assertEquals(0, Combat.resolve(ctx, Rng(3)).damageToAttacker) } @Test @@ -152,7 +160,7 @@ class CombatTest { assertTrue("veteran $veteran should beat fresh $fresh", veteran > fresh) val hurt = Combat.previewDamage( - context(unit(UnitKind.ARMOUR, hp = 30), unit(UnitKind.INFANTRY, nation = 1)) + context(unit(UnitKind.ARMOUR, hp = 60), unit(UnitKind.INFANTRY, nation = 1)) ) assertTrue("wounded $hurt should be worse than fresh $fresh", hurt < fresh) } @@ -215,12 +223,6 @@ class CombatTest { assertTrue("正常 $steady > 士氣下降 $shaken", steady > shaken) assertTrue("士氣下降 $shaken > 士氣嚴重下降 $broken", shaken > broken) assertEquals("混亂完全打不出傷害", 0, hit(ArmyUnit.MIN_MORALE)) - - // 懲罰要比獎勵有感,否則「把對方圍起來」不會是划算的投資。 - assertTrue( - "低落的懲罰應該大於高昂的獎勵", - (steady - shaken) > (elevated - steady) - ) } @Test @@ -231,10 +233,8 @@ class CombatTest { val afloat = Combat.previewDamage( context(unit(UnitKind.DESTROYER), unit(UnitKind.INFANTRY, nation = 1), defenderAtSea = true) ) - // 攻防比公式的傷害上限是 55,而岸上已經打到 24,所以理論最大倍率 - // 只有 2.29 倍 —— 原本斷言「超過兩倍」等於要求貼著公式天花板, - // 是門檻訂錯了,不是機制沒生效。真正的懲罰在下面:完全無法還手。 - assertTrue("浮渡的陸軍應該明顯好打 $afloat vs $ashore", afloat > ashore * 3 / 2) + // 浮渡中沒有防禦、另外多挨五成;真正致命的是下面那條:完全無法還手。 + assertTrue("浮渡的陸軍應該明顯好打 $afloat vs $ashore", afloat > ashore * 7 / 5) assertFalse( "浮渡的陸軍還不了手", Combat.canRetaliate( @@ -267,7 +267,10 @@ class CombatTest { @Test fun `unit roster is internally consistent`() { for (kind in UnitKind.ALL) { - assertEquals("$kind 攻擊值必須有四項", 4, kind.attack.size) + assertEquals("$kind 相剋表必須有四項", 4, kind.versus.size) + assertTrue("$kind 攻擊下限不得大於上限", kind.attackMin <= kind.attackMax) + assertEquals("$kind 有攻擊擲骰就要打得到某類目標", kind.attackMax > 0, kind.canAttack) + assertTrue("$kind 生命必須為正", kind.hp > 0) assertTrue("$kind 防禦必須為正", kind.defence > 0) assertTrue("$kind 移動必須為正", kind.movement > 0) assertTrue("$kind 成本必須為正", kind.cost > 0) @@ -284,6 +287,111 @@ class CombatTest { } } + // ------------------------------------------------------------------ + // 參考公式的數值(docs/original-behavior.md 第 6 節) + // ------------------------------------------------------------------ + + @Test + fun `each point of defence widens the divisor by 1_6 percent`() { + assertEquals(100, Combat.defended(100, 0)) + assertEquals("防禦 1 → 1000/1016", 21, Combat.defended(22, 1)) + assertEquals("防禦 125 剩三分之一", 333, Combat.defended(1000, 125)) + assertEquals("防禦 10 → 1000/1160", 34, Combat.defended(40, 10)) + } + + @Test + fun `one hit follows the reference formula step by step`() { + fun hit(ctx: CombatContext, roll: Int) = Combat.hit(ctx, roll, ctx.attacker.hp, exchange = false) + + // 步兵打步兵,平原、滿血、士氣正常:22 × 1000 / 1016 = 21.65 → 21。 + val plain = context(unit(UnitKind.INFANTRY), unit(UnitKind.INFANTRY, nation = 1)) + assertEquals(21, hit(plain, 22).toUnit) + + // HP 不到一半,攻擊% 減半:⌊0.5 × 22⌋ = 11 → 11000/1016 = 10。 + val wounded = unit(UnitKind.INFANTRY).apply { hp = maxHp / 2 - 1 } + assertEquals(10, hit(context(wounded, unit(UnitKind.INFANTRY, nation = 1)), 22).toUnit) + // 剛好一半不算殘血。 + val half = unit(UnitKind.INFANTRY).apply { hp = maxHp / 2 } + assertEquals(21, hit(context(half, unit(UnitKind.INFANTRY, nation = 1)), 22).toUnit) + + // 等級的固定攻擊不受士氣放大:3 級 +8,士氣 −2 → 8 + ⌊0.5 × 20⌋ = 18 → 18000/1016 = 17。 + val veteran = context( + unit(UnitKind.INFANTRY, level = 3), unit(UnitKind.INFANTRY, nation = 1), attackerMorale = -2 + ) + assertEquals(17, hit(veteran, 20).toUnit) + + // 戰車打山上的步兵:40 × 1000 / 1016 = 39,再 × 0.8 = 31.2 → 31。 + val uphill = context(unit(UnitKind.ARMOUR), unit(UnitKind.INFANTRY, nation = 1), defenderTerrain = Terrain.MOUNTAIN) + assertEquals(31, hit(uphill, 40).toUnit) + + // 傷害至少 1。 + val feeble = context(unit(UnitKind.SUBMARINE), unit(UnitKind.BATTLESHIP, nation = 1).apply { level = 5 }) + assertTrue(hit(feeble, 1).toUnit >= 1) + } + + @Test + fun `a garrison with walls takes half and the walls take the roll undefended`() { + val ctx = context(unit(UnitKind.INFANTRY), unit(UnitKind.INFANTRY, nation = 1), defenderInCity = true) + // 部隊:20 → 19,× 0.5 = 9。城牆沒有防禦:20;有還手的交火 × 0.7 = 14。 + val exchange = Combat.hit(ctx, 20, ctx.attacker.hp, exchange = true) + assertEquals(9, exchange.toUnit) + assertEquals(14, exchange.toCity) + assertEquals(20, Combat.hit(ctx, 20, ctx.attacker.hp, exchange = false).toCity) + } + + @Test + fun `the counter is full strength but uses the defender's hp after the hit`() { + val attacker = unit(UnitKind.INFANTRY) + val defender = unit(UnitKind.INFANTRY, nation = 1) + val ctx = context(attacker, defender) + val untouched = Combat.previewRetaliation(ctx, 0) + val asFirstStrike = Combat.previewDamage(context(defender, attacker)) + assertEquals("反擊不打折", asFirstStrike, untouched) + val crippled = Combat.previewRetaliation(ctx, defender.maxHp / 2 + 1) + assertTrue("挨打到半血以下,反擊減半 $crippled vs $untouched", crippled < untouched * 3 / 4) + assertEquals("被打死就沒有反擊", 0, Combat.previewRetaliation(ctx, defender.maxHp)) + } + + @Test + fun `attack rolls are uniform integers between min and max`() { + val rng = Rng(99) + val seen = HashSet() + repeat(2_000) { + val r = Combat.roll(UnitKind.INFANTRY, rng) + assertTrue(r in UnitKind.INFANTRY.attackMin..UnitKind.INFANTRY.attackMax) + seen.add(r) + } + assertEquals("每個值都擲得到", UnitKind.INFANTRY.attackMax - UnitKind.INFANTRY.attackMin + 1, seen.size) + } + + @Test + fun `max hp comes from kind, formation and level`() { + assertEquals(80, ArmyUnit.maxHpFor(UnitKind.INFANTRY, 1, 1)) + assertEquals("編制 3 = 210%", 168, ArmyUnit.maxHpFor(UnitKind.INFANTRY, 3, 1)) + assertEquals("180 × 160% + 2 級 × 20", 328, ArmyUnit.maxHpFor(UnitKind.ARMOUR, 2, 3)) + + // 改編制或等級時 HP 依比例縮放。 + val u = ArmyUnit(1, UnitKind.ARMOUR, 0, 0) + u.hp = 90 + u.size = 2 + assertEquals(144, u.hp) + u.level = 2 + assertEquals(154, u.hp) + assertEquals(308, u.maxHp) + } + + @Test + fun `healing and starvation work in shares of max hp`() { + val u = ArmyUnit(1, UnitKind.ARMOUR, 0, 0) + u.hp = 100 + u.healPercent(22) + assertEquals(139, u.hp) + u.losePercent(8) + assertEquals(125, u.hp) + u.healPercent(100) + assertEquals(u.maxHp, u.hp) + } + @Test fun `commanders reference real skills and unique ids`() { val ids = Commander.ALL.map { it.id } diff --git a/docs/original-behavior.md b/docs/original-behavior.md index 6256221..d21d00f 100644 --- a/docs/original-behavior.md +++ b/docs/original-behavior.md @@ -18,6 +18,8 @@ SPDX-License-Identifier: GPL-3.0-or-later |---|---| | 觀察 | 維護者實際遊玩原版所觀察到的行為 | | 說明 | 原版遊戲內的說明文字(未加密的文字資源),此處只轉述意思,不引用原文 | +| 逆向 | 維護者對原版原生程式庫所做的靜態分析(反組譯),此處只記錄推導出的規則與數學關係 | +| 資料 | 維護者從原版資料表讀出的數值,此處只記錄作為行為規格所需的數字 | | 標記 | 意思 | |---|---| @@ -25,7 +27,11 @@ SPDX-License-Identifier: GPL-3.0-or-later | 🟡 | 有力推測,尚未以控制實驗驗證 | | ❓ | **UNKNOWN BEHAVIOR** —— 證據不足,本專案的做法是近似值 | -不使用的來源:原版加密資料檔的解密內容、反組譯或反編譯的程式碼。 +「逆向」與「資料」兩種來源自 2026-09-27 起使用(維護者的決定)。使用方式: +先把原版的程式行為整理成規則與數字,寫進這份文件,再依規則獨立實作;這份文件與 +本專案的原始碼都不包含原版的程式碼、資料檔或資源。原版的規則是否採用逐項決定 —— +本專案有原版沒有的機制(補給線、斷補給會掉血),衝突時以本專案的設計為準,並在 +各節的「本專案」一欄註明。 --- @@ -214,14 +220,99 @@ enemies(P) = 與玩家 P 分屬敵對陣營的國家 ∪ 目前與 P 交戰中 --- -## 6. 待研究 +## 6. 戰鬥傷害 + +### 觀察到的行為 + +| 行為 | 來源 | 狀態 | +|---|---|---| +| 一次攻擊的攻擊值在兵種的最小攻擊與最大攻擊之間均勻取整數(含兩端) | 逆向 | ✅ | +| 傷害 = 攻擊值 × 1000 / (防禦 × 16 + 1000),整數除法;每 1 點防禦讓分母多 1.6% | 逆向 | ✅ | +| 攻擊值 = 固定加成 + ⌊攻擊% × 擲骰⌋;等級的加攻屬於固定加成,不受士氣與殘血影響 | 逆向 | ✅ | +| 攻擊% = 士氣(+1 → 125、0 → 100、−1 → 75、−2 → 50)× 編制 × 殘血門檻 | 逆向 | ✅ | +| 殘血門檻:HP 低於最大值的 50% 時攻擊% 減半;剛好 50% 不減 | 逆向、資料 | ✅ | +| 士氣 −3(混亂)不能反擊 | 逆向 | ✅ | +| 反擊與主動攻擊走同一條公式,不打折;反擊時用的是挨打之後的 HP | 逆向 | ✅ | +| 地形不替守方加防禦;只有攻方是裝甲或火炮時,依守方所在地形扣傷害 | 逆向、資料 | ✅ | +| 地形扣傷(裝甲 / 火炮):沙漠 10/10、丘陵 10/5、山 20/10、樹林 5/10、森林 10/20,其他 0 | 資料 | ✅ | +| 守方所在城市的城防還在時,部隊只吃 0.5 倍(取代地形那一項),城防另外承受一份 | 逆向 | ✅ | +| 城防本身防禦 0:承受的是同一擲骰的攻擊值(不經防禦分母) | 逆向 | ✅ | +| 各步驟之間都是無條件捨去;最後的傷害最少 1 | 逆向 | ✅ | +| 編制 1–4:攻擊 100/125/150/175%,生命 100/160/210/250% | 資料 | ✅ | +| 等級每升一級:生命 +20、固定攻擊 +4、防禦 +2 | 資料 | ✅ | +| 最大 HP = 兵種 HP × 編制% + 等級加成(另有將領、科技等加成) | 逆向 | ✅ | +| 重算最大 HP 時,目前 HP 依比例縮放 | 逆向 | ✅ | +| 城防上限:小城市 150、中城市 300、大城市 450、大都市 600、首都 800 | 資料 | ✅ | +| 同規則的公式由維護者在原版實機比對過部隊面板的攻擊、防禦、HP,全部吻合 | 觀察 | ✅ | + +### 規格 + +```text +擲骰 r ∈ [最小攻擊, 最大攻擊],均勻整數 +攻擊% p = 士氣% × 編制% / 100 × (HP×100 < 最大HP×50 ? 50% : 100%) +攻擊值 v = 固定攻擊 + ⌊p/100 × r⌋ +傷害 d = ⌊v × 1000 / (防禦 × 16 + 1000)⌋ + d = ⌊d × 其他倍率⌋ + d = ⌊d × (城防還在 ? 0.5 : 1 − 地形扣傷(守方格, 攻方兵種))⌋ + d = max(1, d) +城防 c = ⌊v × 其他倍率⌋,交火時 × 0.7(見 UNKNOWN 1),最少 1 +交戰 攻方打一擊 → 守方活著且能還手 → 守方以挨打後的 HP 打一擊 +``` + +例:攻擊 22、防禦 1 → 22 × 1000 / 1016 = 21;防禦 62.5 讓傷害減半;防禦 125 剩三分之一。 + +### 本專案 + +- 實作:`units/Combat.kt`(`hit`、`resolve`、`defended`、`attackPercent`、`terrainFactor`); + 最大 HP 與編制、等級:`units/ArmyUnit.kt`(`maxHpFor`);地形扣傷:`world/Terrain.kt`; + 城防上限:`world/Province.kt`(`maxCityHp`)。 +- 兵種的攻擊範圍、HP、防禦取原版同類兵種的量級(例如步兵 16–22 / 80 / 1、中型戰車 28–44 / 180 / 10、 + 驅逐艦 32–52 / 260 / 12),但兵種表是本專案自己的,不一一對應。 +- **本專案保留的規則**,以乘數插在原版公式的對應位置: + - 兵種相剋表(對步兵、裝甲、艦艇、飛機各一個百分比)—— 原版用兵種特性做類似的事。 + - 補給:攻擊% 乘補給係數;防禦乘 0.5 + 0.5 × 補給係數。 + - 科技與司令部光環:加在攻擊%;科技另外放大防禦。原版的科技是加固定點數。 + - 指揮官:攻方的傷害倍率、守方的減傷,位置相當於原版的將領技能。 + - 築壕:每級減傷 5%。 + - 浮渡中的陸軍:防禦 0、攻擊% × 0.3、多挨 50% 傷害、不能還手。原版的登船部隊防禦同樣是 0, + 多挨的傷害來自專打登船部隊的兵種特性;本專案把它簡化成一個固定倍率。 + - 打城牆的倍率取兵種「對步兵」與「對裝甲」較高的那一欄。 + - 經驗:依打掉對方最大 HP 的比例計算。原版的經驗規則未研究。 +- **本專案沒有做的**:暴擊、穿甲、將領的固定攻擊與 HP 加成、首都 800(本專案的城市沒有首都等級, + 最高 600)、原版的等級 6(本專案最高 5 級)。 +- 回血、斷補給失血、城防修復改成最大值的百分比(城裡 22%、野外 6%、斷補給 −8%、城防 5%)。 + 這些是本專案的規則,原版沒有對應的補給機制。 +- 移除:舊的「傷害 = 55 × 攻 / (攻 + 防)」比值公式、±8% 亂數、反擊六折、地形防禦加成、 + 被貼身的火炮防禦 × 0.55。存檔格式升到 3 版,舊存檔視為過時。 +- 測試:`CombatTest` 的 + ``each point of defence widens the divisor by 1_6 percent``、 + ``one hit follows the reference formula step by step``、 + ``a garrison with walls takes half and the walls take the roll undefended``、 + ``the counter is full strength but uses the defender's hp after the hit``、 + ``attack rolls are uniform integers between min and max``、 + ``max hp comes from kind, formation and level``、 + ``terrain hampers tanks and guns attacking into it, not infantry``。 + +### UNKNOWN BEHAVIOR + +1. **城防分攤的 0.7。** 逆向結果確定城防那一份在某個條件下乘 0.7、否則乘 1。從呼叫端推測 + 條件是「守方會還手」,但也有另一種讀法:「這一擊本身是反擊」。本專案採前者:守方會 + 還手的交火(以及反擊本身)乘 0.7,打不還手的目標(遠程砲擊、空襲)整份。 + *實驗*:在原版用火炮從兩格外打一座有守軍的城,再用步兵貼身打同一座城,比較城防掉的量。 +2. **升級時目前 HP 怎麼變。** 原版載入時依比例縮放;戰鬥中升級是否同樣處理未確認。 + 本專案一律依比例縮放。 + *實驗*:讓一支殘血部隊在戰鬥中升級,記錄升級前後的 HP 與最大 HP。 +3. **城防修復速度與易主後的城防。** 原版未研究;本專案維持自己的規則(每回合 5%、易主剩 35%)。 + +--- + +## 7. 待研究 以下行為會影響後續的實作,但目前沒有足夠證據,先列出來: -- 傷害公式與隨機範圍(最小攻擊到最大攻擊之間的分佈、取整時點) -- 編隊、等級對攻擊與生命值的影響 -- 城市與部隊同格時的傷害分攤 +- 暴擊、兵種特性、將領技能的完整條件(逆向筆記已有部分,尚未決定是否採用) +- 經驗值的取得規則與升級門檻 - 經濟:每回合收入的計算方式與時點 -每一項都應該先在原版上做控制實驗(固定兵種、地形、編隊,重複攻擊並記錄結果), -把輸入與輸出寫進這份文件,再決定本專案的實作。 +每一項都應該先整理成規則與數字寫進這份文件,能在原版上做控制實驗的就先做, +再決定本專案的實作。 diff --git a/tools/genstrings.py b/tools/genstrings.py index 2fead96..4c8b713 100644 --- a/tools/genstrings.py +++ b/tools/genstrings.py @@ -99,7 +99,9 @@ "event_city_shelled": ("%1$s took %2$d damage to its defences", "%1$s 的城防被打掉 %2$d", "%1$s 的城防被打掉 %2$d"), "hud_city_defence": ("City defence %1$d/%2$d", "城防 %1$d/%2$d", "城防 %1$d/%2$d"), - "hud_terrain_line": ("%1$s · defence +%2$d%%", "%1$s · 防禦 +%2$d%%", "%1$s · 防御 +%2$d%%"), + "hud_terrain_line": ("%1$s · armour −%2$d%% · artillery −%3$d%%", + "%1$s · 裝甲 −%2$d%% · 火炮 −%3$d%%", + "%1$s · 装甲 −%2$d%% · 火炮 −%3$d%%"), "hud_unit_level": ("Level %1$d · moves %2$d/%3$d", "等級 %1$d · 移動 %2$d/%3$d", "等级 %1$d · 移动 %2$d/%3$d"), "hud_entrenched": ("Entrenched %1$d", "築壕 %1$d 級", "筑壕 %1$d 级"), "hud_ai_turn": ("%1$s is moving…", "%1$s 行動中…", "%1$s 行动中…"), @@ -110,7 +112,9 @@ "panel_production_subtitle": ("%1$s · industry %2$d · funds %3$d", "%1$s · 工業 %2$d · 資金 %3$d", "%1$s · 工业 %2$d · 资金 %3$d"), - "panel_production_stats": ("ATK %1$d DEF %2$d MOV %3$d", "攻 %1$d 防 %2$d 動 %3$d", "攻 %1$d 防 %2$d 动 %3$d"), + "panel_production_stats": ("ATK %1$d–%2$d DEF %3$d HP %4$d MOV %5$d", + "攻 %1$d–%2$d 防 %3$d 血 %4$d 動 %5$d", + "攻 %1$d–%2$d 防 %3$d 血 %4$d 动 %5$d"), "panel_tech": ("Research", "研發", "研发"), "panel_objectives": ("Objectives", "戰役目標", "战役目标"), "panel_standings": ("Standings", "各國局勢", "各国局势"), @@ -144,9 +148,9 @@ "target_ship": ("vs Ships", "對艦艇", "对舰艇"), "target_aircraft": ("vs Aircraft", "對空", "对空"), "unit_level_line": ("Level %1$d · %2$d XP", "等級 %1$d · 經驗 %2$d", "等级 %1$d · 经验 %2$d"), - "unit_stat_line": ("DEF %1$d · MOV %2$d · range %3$d-%4$d · vision %5$d", - "防禦 %1$d · 移動 %2$d · 射程 %3$d-%4$d · 視野 %5$d", - "防御 %1$d · 移动 %2$d · 射程 %3$d-%4$d · 视野 %5$d"), + "unit_stat_line": ("HP %1$d/%2$d · DEF %3$d · MOV %4$d · range %5$d-%6$d · vision %7$d", + "血 %1$d/%2$d · 防禦 %3$d · 移動 %4$d · 射程 %5$d-%6$d · 視野 %7$d", + "血 %1$d/%2$d · 防御 %3$d · 移动 %4$d · 射程 %5$d-%6$d · 视野 %7$d"), "unit_assign_commander": ("Assign commander", "指派指揮官", "指派指挥官"), # 指揮官 @@ -273,19 +277,26 @@ "或是纯粹三次谣言,结果都一样。" "正因为它读的是处境,包围圈一松开,士气就立刻回来。"), "help_combat_body": ( - "Every unit has four separate attack values: against infantry, armour, ships, and anti-air fire. " - "Anti-tank guns shred armour and bounce off infantry. " - "Artillery strikes from two or more hexes away and takes no return fire, but is nearly helpless once something reaches it — " - "keep infantry in front of it. Terrain, city walls and entrenchment all raise the defender's strength, " - "so attacking a dug-in unit in the mountains is a very different proposition from catching it on open farmland.", - "每支部隊都有四個獨立的攻擊值:對步兵、對裝甲、對艦艇、對空(對空就是防空火力)。" - "反坦克炮打戰車勢如破竹,打步兵卻軟弱無力。" - "火炮從兩格以外開火且不會被反擊,可是一旦被貼身就幾乎沒有自衛能力 —— 前面一定要有步兵擋著。" - "地形、城牆與築壕都會提高守方的防禦,所以「打山裡挖好壕溝的敵人」跟「在農地上逮到它」是兩回事。", - "每支部队都有四个独立的攻击值:对步兵、对装甲、对舰艇、对空(对空就是防空火力)。" - "反坦克炮打战车势如破竹,打步兵却软弱无力。" - "火炮从两格以外开火且不会被反击,可是一旦被贴身就几乎没有自卫能力 —— 前面一定要有步兵挡着。" - "地形、城墙与筑壕都会提高守方的防御,所以「打山里挖好壕沟的敌人」跟「在农地上逮到它」是两回事。"), + "Every attack rolls a number between the unit's minimum and maximum attack, and each point of the target's " + "defence trims a little off the hit — never all of it. Units have separate strengths against infantry, armour, " + "ships and aircraft: anti-tank guns shred armour and bounce off infantry. A unit below half its HP hits at half " + "strength, and a defender that survives always fires back at full strength. " + "Artillery strikes from two or more hexes away and takes no return fire, but cannot fire back once something " + "reaches it — keep infantry in front of it. Terrain does not shield the defender; it hampers tanks and guns " + "attacking into it (hills, mountains, forest, jungle, desert), which is why infantry holds the high ground. " + "Entrenchment and city walls cut the damage a defender takes.", + "每次攻擊在部隊的最小與最大攻擊之間擲一個數,目標的每一點防禦都會把傷害削掉一些 —— 但永遠削不完。" + "部隊對步兵、裝甲、艦艇、飛機各有不同的效果:反坦克炮打戰車勢如破竹,打步兵卻軟弱無力。" + "HP 不到一半的部隊攻擊減半;挨打後還活著的守方一定會全力反擊。" + "火炮從兩格以外開火且不會被反擊,可是一旦被貼身就還不了手 —— 前面一定要有步兵擋著。" + "地形不會替守方擋傷害,而是讓打進去的戰車與火炮打折(丘陵、山地、森林、叢林、沙漠)," + "所以守高地的是步兵。築壕與城牆會減少守方挨的傷害。", + "每次攻击在部队的最小与最大攻击之间掷一个数,目标的每一点防御都会把伤害削掉一些 —— 但永远削不完。" + "部队对步兵、装甲、舰艇、飞机各有不同的效果:反坦克炮打战车势如破竹,打步兵却软弱无力。" + "HP 不到一半的部队攻击减半;挨打后还活着的守方一定会全力反击。" + "火炮从两格以外开火且不会被反击,可是一旦被贴身就还不了手 —— 前面一定要有步兵挡着。" + "地形不会替守方挡伤害,而是让打进去的战车与火炮打折(丘陵、山地、森林、丛林、沙漠)," + "所以守高地的是步兵。筑壕与城墙会减少守方挨的伤害。"), "morale_elevated": ("Elevated", "士氣高昂", "士气高昂"), "morale_steady": ("Steady", "正常", "正常"), "morale_shaken": ("Shaken", "士氣下降", "士气下降"),