Three Kingdoms: Warlord's Fate — complete playable game
- Stylized 3D ink-painting map of China (12 provinces, 32 cities, 17 factions) - Custom warlord creation (8 origins, banner, starting city) or historical factions - City management: 7 buildings, 5 dev tiers, recruitment from levies - Character system: stats, traits, loyalty, relationships, wounds, capture, death, succession - Turn-based tactical battles with formations, stances, hero skills, cinematic 3D replay - Sieges: assault, starvation, bribery, infiltration - Diplomacy with trust memory, alliances, NAPs, trade, marriage, espionage, betrayal - Scripted diverging history (Dong Zhuo, Guandu, Red Cliffs...) + world crises + court events - AI factions with distinct personalities; prisoners (execute/release/recruit/ransom) - Procedural guqin/taiko WebAudio score; save/load; victory + dynasty chronicle screens - View-relative camera controls; headless test suites (smoke, stress, map validator)
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
shots/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
@@ -0,0 +1,69 @@
|
||||
# THREE KINGDOMS: WARLORD'S FATE 三國:群雄命運
|
||||
|
||||
A complete strategy-RPG in the dying years of the Han dynasty. Raise a banner as a
|
||||
nobody with one city — or command one of 16 historical factions — and write your
|
||||
own chronicle across a stylized ink-painting 3D map of China.
|
||||
|
||||
Every campaign generates different history: Dong Zhuo may fall to his adopted son,
|
||||
Yuan Shu may crown himself, the Yellow Turbans may rise again, and somewhere in a
|
||||
quiet village the greatest mind of the age is waiting to be found.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
cd warlords-fate
|
||||
python3 -m http.server 8901
|
||||
# open http://127.0.0.1:8901/
|
||||
```
|
||||
|
||||
No build step. Vanilla ES modules + Three.js (vendored at `js/vendor/three.module.js`).
|
||||
|
||||
## The loop
|
||||
|
||||
END TURN advances one month: taxes & harvests → salaries → AI factions scheme →
|
||||
sieges grind on → events fire → officers drift loyal or restless.
|
||||
|
||||
- **Cities** — upgrade farms/markets/walls/academies, recruit from levies, march an
|
||||
idle general out with a drafted host.
|
||||
- **War** — move armies between adjacent provinces. Attacking opens a cinematic
|
||||
battle: pick a formation, give a stance, watch hero skills trigger in slow motion.
|
||||
Sieges can also be starved out, bribed, or infiltrated.
|
||||
- **Officers** — stats, traits, loyalty, sworn brothers, rivals, wounds, death, and
|
||||
capture. Execute, release, ransom, or persuade prisoners. Search cities for
|
||||
hidden talents.
|
||||
- **Diplomacy** — alliances, non-aggression pacts, trade pacts, marriage, tribute
|
||||
demands, espionage. Trust is remembered; betrayal poisons every court.
|
||||
- **Events** — scripted history that can diverge plus world crises and court drama,
|
||||
all with meaningful choices.
|
||||
- **Victory** — hold 20 cities, destroy every rival, or claim the Mandate of Heaven.
|
||||
Then read the Chronicle of Your Dynasty.
|
||||
|
||||
## Tests
|
||||
|
||||
Headless logic suites (no browser needed):
|
||||
|
||||
```bash
|
||||
node tools/smoke.mjs # 60-turn run incl. save/load roundtrip
|
||||
node tools/stress.mjs # 4 scenarios: passive/aggressive/AI-campaign/challenge
|
||||
node tools/check-map.mjs # map integrity validator
|
||||
```
|
||||
|
||||
Browser journey was verified end-to-end with Puppeteer (screenshots in `shots/`).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
index.html DOM shell: screens, HUD, panels, modals
|
||||
css/style.css ink/parchment/bronze theme
|
||||
js/data.js map ASCII, 32 cities, 17 factions, ~69 generals, units, buildings…
|
||||
js/state.js global state, RNG, save/load
|
||||
js/world.js hex grid geometry + province adjacency
|
||||
js/sim.js economy, battles, sieges, AI, turn engine
|
||||
js/events.js scripted history + crises + court events
|
||||
js/map3d.js stylized 3D China (instanced hexes, pagodas, rivers, banners)
|
||||
js/battle3d.js cinematic tactical battle scene
|
||||
js/battle-sim… pure battle simulation driving the replay
|
||||
js/audio.js procedural guqin/drums/gong WebAudio score
|
||||
js/ui.js panels, HUD, event modals, portraits
|
||||
js/main.js bootstrap, game loop, orchestration
|
||||
```
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
/* ============ WARLORD'S FATE — ink & parchment UI ============ */
|
||||
:root{
|
||||
--parch:#e8d9b5; --parch2:#dcc79a; --ink:#241a10; --ink2:#3a2c1c;
|
||||
--wood:#1d1410; --wood2:#2b1f16; --bronze:#a8874f; --bronze2:#c8a869;
|
||||
--seal:#a02c20; --gold:#d8b25f; --green:#6fa06a; --blue:#5b7fb4;
|
||||
--panel:#171009ee; --panel-line:#5a4527;
|
||||
--serif:'Noto Serif SC','Songti SC',Georgia,serif;
|
||||
--disp:'Cinzel','Noto Serif SC',serif;
|
||||
--brush:'Ma Shan Zheng','Noto Serif SC',cursive;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{width:100%;height:100%;overflow:hidden;background:#0d0906}
|
||||
body{font-family:var(--serif);color:var(--parch);font-size:14px;-webkit-font-smoothing:antialiased}
|
||||
.hidden{display:none!important}
|
||||
#app{position:relative;width:100vw;height:100vh;overflow:hidden}
|
||||
|
||||
canvas#gl{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:grab}
|
||||
canvas#gl.picking{cursor:pointer}
|
||||
#labels{position:absolute;inset:0;pointer-events:none;overflow:hidden}
|
||||
|
||||
/* ---------- screens ---------- */
|
||||
.screen{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;z-index:50}
|
||||
#title-screen{background:
|
||||
radial-gradient(1200px 700px at 70% 15%, rgba(216,178,95,.13), transparent 60%),
|
||||
radial-gradient(900px 600px at 20% 85%, rgba(160,44,32,.12), transparent 60%),
|
||||
linear-gradient(180deg,#12100c 0%, #191209 40%, #0d0906 100%)}
|
||||
.title-ink{position:absolute;inset:0;opacity:.5;background:
|
||||
radial-gradient(600px 300px at 78% 30%, rgba(232,217,181,.07), transparent 70%),
|
||||
radial-gradient(500px 260px at 12% 60%, rgba(232,217,181,.05), transparent 70%)}
|
||||
.title-inner{position:relative;text-align:center;max-width:760px;padding:30px;z-index:2}
|
||||
.title-seal{position:absolute;top:-8px;right:-30px;width:96px;height:96px;border:3px solid var(--seal);
|
||||
color:var(--seal);font-family:var(--brush);font-size:30px;line-height:1.05;display:flex;align-items:center;justify-content:center;
|
||||
transform:rotate(6deg);border-radius:10px;background:rgba(160,44,32,.08);box-shadow:0 0 24px rgba(160,44,32,.35), inset 0 0 18px rgba(160,44,32,.25);opacity:.92}
|
||||
.game-title{font-family:var(--disp);font-weight:900;font-size:64px;letter-spacing:.14em;color:var(--parch);
|
||||
text-shadow:0 2px 0 #000,0 0 42px rgba(216,178,95,.28);margin-bottom:6px}
|
||||
.game-subtitle{font-size:17px;color:var(--gold);letter-spacing:.34em;margin-bottom:22px;font-weight:600}
|
||||
.title-tag{color:#cdbb93;line-height:1.75;margin-bottom:30px;font-size:15px}
|
||||
.title-menu{display:flex;flex-direction:column;gap:11px;align-items:center}
|
||||
.title-foot{margin-top:36px;color:#7d6c4e;font-size:12px;letter-spacing:.12em}
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
.btn{font-family:var(--disp);letter-spacing:.09em;font-size:14px;color:var(--parch);
|
||||
background:linear-gradient(180deg,#33261a,#211709);border:1px solid var(--panel-line);
|
||||
padding:11px 26px;border-radius:4px;cursor:pointer;min-width:280px;transition:.16s;position:relative}
|
||||
.btn:hover{border-color:var(--bronze2);color:#fff;box-shadow:0 0 18px rgba(200,168,105,.22);transform:translateY(-1px)}
|
||||
.btn-primary{border-color:var(--seal);background:linear-gradient(180deg,#5d2018,#38120c);color:#f5e6c8}
|
||||
.btn-primary:hover{box-shadow:0 0 22px rgba(160,44,32,.5)}
|
||||
.btn.small{min-width:0;padding:7px 14px;font-size:12px}
|
||||
.btn:disabled{opacity:.4;cursor:default;transform:none}
|
||||
|
||||
/* ---------- setup / faction pick ---------- */
|
||||
.setup-scroll,.end-scroll{max-height:92vh;overflow-y:auto;width:min(1060px,94vw);
|
||||
background:linear-gradient(180deg,#20170e 0%,#170f08 100%);
|
||||
border:1px solid var(--panel-line);border-radius:10px;padding:28px 34px;
|
||||
box-shadow:0 30px 80px rgba(0,0,0,.7), inset 0 0 80px rgba(0,0,0,.5)}
|
||||
.panel-title{font-family:var(--disp);font-size:26px;letter-spacing:.12em;color:var(--gold);margin-bottom:18px;text-align:center}
|
||||
.panel-title .cn{font-family:var(--brush);color:var(--seal);margin-left:10px;font-size:24px}
|
||||
.setup-section{margin-bottom:20px}
|
||||
.setup-section h3{color:var(--bronze2);letter-spacing:.14em;font-size:14px;font-weight:600;margin-bottom:10px;border-bottom:1px solid #3a2c18;padding-bottom:5px}
|
||||
.cn-small{font-family:var(--brush);opacity:.75;font-size:13px;margin-left:6px}
|
||||
.choice-row{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.choice-row.small{gap:6px}
|
||||
.choice{padding:8px 14px;border:1px solid var(--panel-line);border-radius:4px;cursor:pointer;background:#1d150c;font-size:13px;transition:.15s}
|
||||
.choice:hover{border-color:var(--bronze2)}
|
||||
.choice.sel{border-color:var(--seal);background:linear-gradient(180deg,#4a1c14,#2a100a);color:#f2dfba;box-shadow:0 0 12px rgba(160,44,32,.4)}
|
||||
.hint-box{margin-top:10px;color:#c9b68d;font-size:12.5px;line-height:1.65;min-height:38px;padding:9px 12px;background:#140e07;border-left:3px solid var(--bronze);border-radius:2px}
|
||||
.setup-columns{display:grid;grid-template-columns:1fr 1.2fr;gap:26px}
|
||||
.setup-section label{display:flex;align-items:center;gap:10px;margin-bottom:10px;color:#cdbb93;font-size:13px}
|
||||
.setup-section input[type=text],.setup-section select{flex:1;background:#120c06;border:1px solid var(--panel-line);color:var(--parch);padding:7px 10px;border-radius:3px;font-family:var(--serif);font-size:14px}
|
||||
.setup-section input[type=color]{width:52px;height:30px;background:none;border:1px solid var(--panel-line);border-radius:3px;padding:2px;cursor:pointer}
|
||||
.city-list,.faction-grid{display:flex;flex-wrap:wrap;gap:7px;max-height:180px;overflow-y:auto;padding-right:6px}
|
||||
.faction-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));max-height:56vh}
|
||||
.faction-card{border:1px solid var(--panel-line);border-radius:6px;padding:12px 14px;cursor:pointer;background:#191108;transition:.15s;position:relative;overflow:hidden}
|
||||
.faction-card:hover{transform:translateY(-2px);border-color:var(--bronze2)}
|
||||
.faction-card.sel{border-color:var(--gold);box-shadow:0 0 16px rgba(216,178,95,.3)}
|
||||
.faction-card .fc-banner{position:absolute;left:0;top:0;bottom:0;width:5px}
|
||||
.faction-card h4{font-size:16px;margin-bottom:3px;letter-spacing:.04em}
|
||||
.faction-card .fc-leader{color:var(--gold);font-size:12.5px;margin-bottom:6px}
|
||||
.faction-card p{font-size:11.5px;color:#b3a17c;line-height:1.55}
|
||||
.fc-cities{margin-top:6px;font-size:11px;color:#8d7c5c}
|
||||
.setup-actions{display:flex;gap:14px;justify-content:center;margin-top:22px}
|
||||
|
||||
/* ---------- HUD top ---------- */
|
||||
#game-screen{z-index:10}
|
||||
#hud-top{position:absolute;top:0;left:0;right:0;height:46px;display:flex;align-items:center;gap:8px;padding:0 12px;z-index:30;
|
||||
background:linear-gradient(180deg,rgba(13,9,5,.94) 0%,rgba(13,9,5,.82) 70%,transparent);
|
||||
border-bottom:1px solid rgba(90,69,39,.35)}
|
||||
.hud-block{display:flex;align-items:center;gap:6px;background:rgba(23,16,9,.72);border:1px solid rgba(90,69,39,.5);
|
||||
padding:4px 11px;border-radius:3px;font-size:13.5px;white-space:nowrap}
|
||||
.hud-block b{font-size:14.5px;letter-spacing:.03em}
|
||||
.res-ico{opacity:.85;font-size:13px}
|
||||
.delta{font-size:11px;color:#9aa87a}
|
||||
.delta.neg{color:#c96a5a}
|
||||
.hud-date b{color:var(--gold);letter-spacing:.05em}
|
||||
.hud-date span{color:#a5946f;font-size:11px;margin-right:7px;letter-spacing:.1em;text-transform:uppercase}
|
||||
.legit b{color:#d8845f}
|
||||
.hud-spacer{flex:1}
|
||||
.icon-btn{background:rgba(23,16,9,.8);border:1px solid var(--panel-line);color:var(--parch);border-radius:3px;
|
||||
width:32px;height:32px;cursor:pointer;font-size:15px;transition:.15s;pointer-events:auto}
|
||||
.icon-btn:hover{border-color:var(--bronze2);color:#fff}
|
||||
#btn-endturn{font-family:var(--disp);font-weight:700;letter-spacing:.1em;color:#f5e6c8;font-size:13.5px;cursor:pointer;
|
||||
background:linear-gradient(180deg,#7c2a1e 0%,#4c170e 100%);border:1px solid #b0552f;border-radius:4px;padding:8px 22px;transition:.18s;
|
||||
box-shadow:0 0 0 rgba(160,44,32,0)}
|
||||
#btn-endturn:hover{box-shadow:0 0 24px rgba(192,74,42,.55);transform:translateY(-1px)}
|
||||
#btn-endturn.busy{opacity:.5;pointer-events:none}
|
||||
|
||||
/* ---------- left objective ---------- */
|
||||
#hud-left{position:absolute;top:56px;left:12px;z-index:25;max-width:290px}
|
||||
.mini-note{background:rgba(23,16,9,.78);border:1px solid rgba(90,69,39,.5);border-left:3px solid var(--gold);
|
||||
border-radius:3px;padding:9px 12px;font-size:12px;line-height:1.6;color:#d6c49b;backdrop-filter:blur(3px)}
|
||||
|
||||
/* ---------- tabs & panel ---------- */
|
||||
#tab-bar{position:absolute;bottom:0;left:0;right:0;height:44px;display:flex;justify-content:center;gap:4px;z-index:30;
|
||||
background:linear-gradient(0deg,rgba(13,9,5,.95) 0%,rgba(13,9,5,.75) 70%,transparent);padding-top:10px}
|
||||
.tab-btn{font-family:var(--serif);font-size:13px;letter-spacing:.06em;color:#bda87e;background:rgba(23,16,9,.7);
|
||||
border:1px solid rgba(90,69,39,.5);border-bottom:none;border-radius:6px 6px 0 0;padding:7px 16px 12px;cursor:pointer;transition:.15s}
|
||||
.tab-btn:hover{color:var(--parch);border-color:var(--bronze2)}
|
||||
.tab-btn.active{color:#ffe9bd;background:linear-gradient(180deg,#33241a,#241a10);border-color:var(--bronze);box-shadow:0 -4px 14px rgba(0,0,0,.4)}
|
||||
#side-panel{position:absolute;top:52px;bottom:48px;right:0;width:min(430px,44vw);z-index:28;display:flex;flex-direction:column;
|
||||
background:linear-gradient(180deg,rgba(20,14,8,.97),rgba(14,9,5,.97));border-left:1px solid var(--panel-line);
|
||||
box-shadow:-18px 0 40px rgba(0,0,0,.5)}
|
||||
.panel-close{position:absolute;top:8px;right:8px;z-index:2}
|
||||
#panel-content{overflow-y:auto;padding:16px;flex:1}
|
||||
#panel-content::-webkit-scrollbar,.city-list::-webkit-scrollbar,.setup-scroll::-webkit-scrollbar{width:8px}
|
||||
#panel-content::-webkit-scrollbar-thumb,.city-list::-webkit-scrollbar-thumb{background:#3a2c18;border-radius:4px}
|
||||
.panel-h{font-family:var(--disp);font-size:17px;color:var(--gold);letter-spacing:.08em;margin-bottom:10px;border-bottom:1px solid #3a2c18;padding-bottom:6px}
|
||||
.sub-h{color:var(--bronze2);font-size:12.5px;letter-spacing:.12em;margin:14px 0 7px;font-weight:600}
|
||||
|
||||
/* general card */
|
||||
.gen-card{display:flex;gap:11px;background:#1a130a;border:1px solid #3a2c18;border-radius:6px;padding:10px;margin-bottom:9px;cursor:pointer;transition:.15s;position:relative}
|
||||
.gen-card:hover{border-color:var(--bronze2);background:#221809}
|
||||
.gen-portrait{width:58px;height:58px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;
|
||||
font-family:var(--brush);font-size:26px;color:#fff;text-shadow:0 1px 3px #000;border:2.5px solid;box-shadow:inset 0 0 16px rgba(0,0,0,.55)}
|
||||
.gen-info{flex:1;min-width:0}
|
||||
.gen-name{font-size:15px;color:#f0dfba;letter-spacing:.04em}
|
||||
.gen-name .age{color:#8d7c5c;font-size:11.5px;margin-left:6px}
|
||||
.gen-title{font-size:11px;color:var(--gold);letter-spacing:.08em;margin:1px 0 5px}
|
||||
.stat-row{display:flex;gap:5px;flex-wrap:wrap}
|
||||
.stat-chip{font-size:10.5px;background:#120c06;border:1px solid #33271a;border-radius:3px;padding:2px 6px;color:#cbb88f}
|
||||
.stat-chip b{color:#efe0ba}
|
||||
.trait-tag{font-size:10px;border:1px solid #4a3a20;border-radius:3px;padding:1px 6px;color:#d8b25f;background:rgba(216,178,95,.07)}
|
||||
.loyalty-bar{height:5px;background:#0e0903;border-radius:3px;margin-top:6px;overflow:hidden}
|
||||
.loyalty-fill{height:100%;border-radius:3px;transition:width .4s}
|
||||
.gen-flags{position:absolute;top:8px;right:9px;display:flex;gap:5px;font-size:11px}
|
||||
.flag-wounded{color:#d86a4a}.flag-battle{color:#e8c66a}.flag-hidden{color:#7ab07a}
|
||||
.bar-wrap{display:flex;align-items:center;gap:8px;margin:3px 0;font-size:12px;color:#c9b68d}
|
||||
.bar-track{flex:1;height:7px;background:#0e0903;border:1px solid #292013;border-radius:4px;overflow:hidden}
|
||||
.bar-fill{height:100%;transition:width .5s}
|
||||
.btn-row{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
|
||||
.btn-tiny{font-family:var(--serif);font-size:11.5px;color:var(--parch);background:#241a10;border:1px solid #4a3a20;border-radius:3px;padding:5px 10px;cursor:pointer;transition:.15s}
|
||||
.btn-tiny:hover{border-color:var(--bronze2);background:#332415}
|
||||
.btn-tiny.warn{border-color:#7c3a2a;color:#eba890}
|
||||
.btn-tiny:disabled{opacity:.35;cursor:default}
|
||||
|
||||
/* city card */
|
||||
.city-card{background:#1a130a;border:1px solid #3a2c18;border-radius:6px;padding:11px;margin-bottom:9px}
|
||||
.city-head{display:flex;justify-content:space-between;align-items:baseline}
|
||||
.city-name{font-size:15.5px;color:#f0dfba}
|
||||
.city-name .prov{color:#8d7c5c;font-size:11px;margin-left:6px}
|
||||
.owner-dot{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:7px;vertical-align:baseline;border:1px solid rgba(255,255,255,.25)}
|
||||
.city-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:4px 10px;margin:8px 0;font-size:11.5px;color:#c9b68d}
|
||||
.city-stats b{color:#eee0ba}
|
||||
.build-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin-top:6px}
|
||||
.build-slot{background:#120c06;border:1px solid #33271a;border-radius:4px;padding:6px;text-align:center;font-size:11px;color:#c9b68d}
|
||||
.build-slot .lv{color:var(--gold);font-weight:700}
|
||||
.build-slot.up{border-color:#5a6b3a;cursor:pointer}
|
||||
.build-slot.up:hover{background:#1d1a0c;border-color:#8fa050}
|
||||
.build-slot.max{border-color:#3a4a2a;color:#9ab07a}
|
||||
|
||||
/* diplomacy */
|
||||
.diplo-row{display:flex;align-items:center;gap:9px;background:#1a130a;border:1px solid #3a2c18;border-radius:6px;padding:9px 11px;margin-bottom:8px}
|
||||
.diplo-flag{width:14px;height:22px;border-radius:2px;flex-shrink:0;border:1px solid rgba(255,255,255,.2);display:flex;align-items:center;justify-content:center;font-size:9px}
|
||||
.diplo-name{flex:1;min-width:0}
|
||||
.diplo-name b{font-size:13.5px}
|
||||
.diplo-rel{font-size:11px;margin-top:2px}
|
||||
.rel-war{color:#e07a5a}.rel-alliance{color:#8fd07a}.rel-nap{color:#8fb0d0}.rel-trade{color:#d0c07a}.rel-peace{color:#b0a480}
|
||||
.trust-meter{width:64px;height:7px;background:#0e0903;border-radius:4px;position:relative;overflow:hidden;flex-shrink:0}
|
||||
.trust-meter::after{content:'';position:absolute;left:50%;top:0;bottom:0;width:1px;background:#5a4527}
|
||||
|
||||
/* journal */
|
||||
.journal-entry{border-left:3px solid #4a3a20;padding:7px 11px;margin-bottom:7px;background:#170f08;border-radius:0 4px 4px 0;font-size:12.5px;line-height:1.55;color:#d6c49b}
|
||||
.journal-entry.war{border-color:#a04a2a}.journal-entry.good{border-color:#6fa06a}.journal-entry.bad{border-color:#c05040}.journal-entry.epic{border-color:#d8b25f;background:#1c1508}
|
||||
.journal-entry .je-date{color:var(--gold);font-size:11px;letter-spacing:.08em;margin-right:8px}
|
||||
.chron-year{color:var(--seal);font-family:var(--brush);font-size:17px;margin:10px 0 4px}
|
||||
|
||||
/* hover card */
|
||||
#hover-card{position:absolute;z-index:40;pointer-events:none;max-width:270px;background:rgba(16,11,6,.95);
|
||||
border:1px solid var(--panel-line);border-radius:5px;padding:10px 12px;font-size:12px;line-height:1.5;color:#d6c49b;
|
||||
box-shadow:0 10px 30px rgba(0,0,0,.6)}
|
||||
#hover-card h4{color:#f0dfba;font-size:14px;margin-bottom:3px}
|
||||
#hover-card .hc-sub{color:var(--gold);font-size:11px;margin-bottom:5px}
|
||||
|
||||
/* map labels */
|
||||
.map-label{position:absolute;transform:translate(-50%,-100%);text-align:center;pointer-events:auto;cursor:pointer;transition:opacity .3s}
|
||||
.ml-name{font-size:12px;color:#f4e8cc;text-shadow:0 1px 2px #000,0 0 8px rgba(0,0,0,.9);letter-spacing:.06em;white-space:nowrap}
|
||||
.ml-sub{font-size:10px;color:#c9b184;text-shadow:0 1px 2px #000;white-space:nowrap}
|
||||
.army-label{position:absolute;transform:translate(-50%,-50%);pointer-events:auto;cursor:pointer;
|
||||
background:rgba(16,11,6,.88);border:1px solid;border-radius:3px;padding:2px 7px;font-size:11px;white-space:nowrap;text-shadow:none}
|
||||
|
||||
/* province tooltip region highlight */
|
||||
.prov-banner{position:absolute;left:50%;top:54px;transform:translateX(-50%);z-index:24;text-align:center;pointer-events:none;opacity:0;transition:opacity .4s}
|
||||
.prov-banner.show{opacity:1}
|
||||
.prov-banner h3{font-family:var(--brush);font-size:26px;color:#f2e3bd;text-shadow:0 2px 6px #000;letter-spacing:.1em}
|
||||
.prov-banner p{font-size:12px;color:#cab98f;text-shadow:0 1px 3px #000}
|
||||
|
||||
/* event modal */
|
||||
.modal{position:absolute;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:rgba(5,3,2,.62);backdrop-filter:blur(2px)}
|
||||
.event-card{width:min(560px,92vw);background:linear-gradient(180deg,#221709 0%,#170f07 100%);
|
||||
border:1px solid var(--bronze);border-radius:8px;padding:26px 30px;box-shadow:0 30px 90px rgba(0,0,0,.8), inset 0 0 60px rgba(0,0,0,.45);
|
||||
animation:eventIn .35s ease-out}
|
||||
@keyframes eventIn{from{opacity:0;transform:translateY(18px) scale(.97)}to{opacity:1;transform:none}}
|
||||
.event-kind{font-family:var(--disp);font-size:11px;letter-spacing:.3em;color:var(--seal);margin-bottom:8px}
|
||||
.event-card h2{font-family:var(--disp);font-size:21px;color:var(--gold);letter-spacing:.05em;margin-bottom:12px;line-height:1.35}
|
||||
.event-art{font-size:44px;text-align:center;margin:6px 0 12px;filter:drop-shadow(0 4px 12px rgba(216,178,95,.25))}
|
||||
.event-card p{color:#d9c89f;line-height:1.8;font-size:13.5px;margin-bottom:18px;white-space:pre-line}
|
||||
.event-choice{display:block;width:100%;text-align:left;font-family:var(--serif);font-size:13.5px;color:var(--parch);
|
||||
background:#1e150b;border:1px solid #4a3a20;border-left:3px solid var(--bronze);border-radius:4px;padding:11px 15px;margin-bottom:9px;cursor:pointer;transition:.15s;line-height:1.5}
|
||||
.event-choice:hover{border-color:var(--bronze2);border-left-color:var(--seal);background:#2a1d0e;transform:translateX(3px)}
|
||||
.event-choice .ec-hint{display:block;font-size:11px;color:#97835b;margin-top:3px}
|
||||
|
||||
/* toasts */
|
||||
#toasts{position:absolute;right:14px;top:110px;z-index:45;display:flex;flex-direction:column;gap:8px;pointer-events:none}
|
||||
.toast{background:rgba(20,14,8,.94);border:1px solid var(--panel-line);border-left:3px solid var(--gold);color:#e5d5ac;
|
||||
padding:9px 14px;border-radius:4px;font-size:12.5px;max-width:320px;animation:toastIn .3s ease-out;box-shadow:0 8px 24px rgba(0,0,0,.5);line-height:1.5}
|
||||
.toast.bad{border-left-color:#c05040}.toast.good{border-left-color:#6fa06a}.toast.epic{border-left-color:#d8b25f;box-shadow:0 0 24px rgba(216,178,95,.3)}
|
||||
@keyframes toastIn{from{opacity:0;transform:translateX(24px)}to{opacity:1}}
|
||||
|
||||
/* turn banner */
|
||||
#turn-banner{position:absolute;inset:0;z-index:55;display:flex;align-items:center;justify-content:center;pointer-events:none}
|
||||
#turn-banner-text{font-family:var(--brush);font-size:64px;color:#f2e3bd;text-shadow:0 4px 24px rgba(0,0,0,.9),0 0 60px rgba(216,178,95,.4);
|
||||
animation:turnFade 1.5s ease forwards;letter-spacing:.12em;text-align:center}
|
||||
@keyframes turnFade{0%{opacity:0;transform:scale(1.12)}18%{opacity:1;transform:scale(1)}75%{opacity:1}100%{opacity:0}}
|
||||
|
||||
/* vignette + grain */
|
||||
#vignette{position:absolute;inset:0;pointer-events:none;z-index:20;
|
||||
background:radial-gradient(ellipse at center, transparent 55%, rgba(8,5,2,.55) 100%)}
|
||||
|
||||
/* battle overlay */
|
||||
#battle-overlay{position:absolute;inset:0;z-index:58;background:#000}
|
||||
#battle-gl{position:absolute;inset:0;width:100%;height:100%}
|
||||
#battle-hud{position:absolute;top:0;left:0;right:0;display:flex;justify-content:space-between;padding:12px 18px;gap:20px;
|
||||
background:linear-gradient(180deg,rgba(10,6,3,.9),transparent)}
|
||||
.bh-side{flex:1;max-width:330px}
|
||||
.bh-right{text-align:right}
|
||||
.bh-name{font-family:var(--disp);font-size:15px;color:#f2e3bd;letter-spacing:.06em;margin-bottom:5px}
|
||||
.bh-bar{height:9px;background:rgba(0,0,0,.6);border:1px solid #5a4527;border-radius:5px;overflow:hidden}
|
||||
.bh-bar div{height:100%;border-radius:4px;transition:width .5s}
|
||||
#bh-att-morale{background:linear-gradient(90deg,#7ca050,#a8c878)}
|
||||
#bh-def-morale{background:linear-gradient(90deg,#c07850,#e0a878);float:right}
|
||||
.bh-troops{font-size:12px;color:#cab98f;margin-top:4px}
|
||||
.bh-center{text-align:center}
|
||||
#bh-round{font-family:var(--disp);font-size:19px;color:var(--gold);letter-spacing:.14em}
|
||||
#bh-terrain{font-size:12px;color:#a5946f;margin-top:3px;letter-spacing:.1em;text-transform:uppercase}
|
||||
#battle-callout{position:absolute;left:0;right:0;bottom:22%;text-align:center;pointer-events:none}
|
||||
.callout{display:inline-block;font-family:var(--brush);font-size:42px;color:#ffe9bd;text-shadow:0 3px 18px rgba(0,0,0,.95),0 0 40px rgba(216,178,95,.45);
|
||||
animation:calloutIn 2.2s ease forwards}
|
||||
@keyframes calloutIn{0%{opacity:0;transform:scale(1.4)}14%{opacity:1;transform:scale(1)}80%{opacity:1}100%{opacity:0}}
|
||||
#battle-speed{position:absolute;right:14px;bottom:14px;width:42px;height:42px;font-size:16px}
|
||||
#battle-result{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(8,5,2,.72)}
|
||||
.br-card{width:min(520px,92vw);background:linear-gradient(180deg,#221709,#170f07);border:1px solid var(--bronze);border-radius:8px;padding:26px 30px;animation:eventIn .4s ease-out}
|
||||
.br-card h2{font-family:var(--disp);font-size:24px;letter-spacing:.1em;margin-bottom:14px;text-align:center}
|
||||
.br-victory{color:#a8d078;text-shadow:0 0 24px rgba(140,200,110,.4)}
|
||||
.br-defeat{color:#e07a5a;text-shadow:0 0 24px rgba(220,110,80,.4)}
|
||||
.br-lines{font-size:13px;line-height:1.9;color:#d9c89f;margin-bottom:16px}
|
||||
.prisoner-actions{display:flex;flex-direction:column;gap:8px;margin:10px 0}
|
||||
|
||||
/* end screen */
|
||||
#end-screen{background:radial-gradient(1000px 600px at 50% 20%, rgba(216,178,95,.1), transparent 60%),linear-gradient(180deg,#151009,#0b0704)}
|
||||
.dynasty-name{text-align:center;font-family:var(--brush);font-size:44px;color:var(--seal);margin:6px 0 18px;text-shadow:0 0 30px rgba(160,44,32,.4)}
|
||||
#end-chronicle{font-family:var(--serif);font-size:13.5px;line-height:2.05;color:#d9c89f;white-space:pre-wrap;margin-bottom:18px}
|
||||
#end-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-bottom:8px}
|
||||
.es-item{background:#191108;border:1px solid #3a2c18;border-radius:5px;padding:10px 12px;text-align:center}
|
||||
.es-item b{display:block;font-size:20px;color:var(--gold)}
|
||||
.es-item span{font-size:11px;color:#97835b;letter-spacing:.08em}
|
||||
|
||||
/* help */
|
||||
.help-cols{columns:2;column-gap:30px;font-size:12.5px;color:#cdbb93;line-height:1.7}
|
||||
.help-cols h4{color:var(--gold);margin:10px 0 4px;break-after:avoid}
|
||||
kbd{background:#2a1d0e;border:1px solid #4a3a20;border-radius:3px;padding:1px 6px;font-size:11px;font-family:monospace}
|
||||
|
||||
@media (max-width:900px){
|
||||
.setup-columns{grid-template-columns:1fr}
|
||||
.help-cols{columns:1}
|
||||
.game-title{font-size:42px}
|
||||
#side-panel{width:60vw}
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Warlord's Fate — a Three Kingdoms Chronicle</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700;900&family=Noto+Serif+SC:wght@400;600;900&family=Ma+Shan+Zheng&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>⚔</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- ======== TITLE SCREEN ======== -->
|
||||
<div id="title-screen" class="screen">
|
||||
<div class="title-ink"></div>
|
||||
<div class="title-inner">
|
||||
<div class="title-seal">漢末<br>群雄</div>
|
||||
<h1 class="game-title">WARLORD'S FATE</h1>
|
||||
<div class="game-subtitle">三国 · 群雄传 — A Three Kingdoms Chronicle</div>
|
||||
<p class="title-tag">The Han collapses. Warlords rise. History is unwritten.<br>Take a city, a banner, and a handful of sworn blades — and carve your name into the age.</p>
|
||||
<div class="title-menu">
|
||||
<button class="btn btn-primary" id="btn-new-campaign">⚑ New Grand Campaign</button>
|
||||
<button class="btn" id="btn-new-challenge">⏳ Hundred Days Trial</button>
|
||||
<button class="btn" id="btn-continue" disabled>↻ Continue Campaign</button>
|
||||
<button class="btn" id="btn-load-title">▤ Load Saved Game</button>
|
||||
<button class="btn" id="btn-help-title">? How to Play</button>
|
||||
</div>
|
||||
<div class="title-foot">An original strategy chronicle · stylized 3D China · every playthrough writes a different history</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== NEW GAME SETUP ======== -->
|
||||
<div id="setup-screen" class="screen hidden">
|
||||
<div class="setup-scroll">
|
||||
<h2 class="panel-title">Raise Your Banner <span class="cn">举旗</span></h2>
|
||||
|
||||
<div class="setup-section">
|
||||
<h3>I. Origin <span class="cn-small">出身</span></h3>
|
||||
<div id="origin-list" class="choice-row"></div>
|
||||
<div id="origin-desc" class="hint-box"></div>
|
||||
</div>
|
||||
|
||||
<div class="setup-columns">
|
||||
<div class="setup-section">
|
||||
<h3>II. Identity</h3>
|
||||
<label>Ruler name <input id="inp-ruler" type="text" maxlength="16" value="Minh"></label>
|
||||
<label>Banner emblem
|
||||
<select id="sel-emblem">
|
||||
<option>⚔</option><option>🐉</option><option>🦅</option><option>🐯</option><option>🐺</option><option>🔥</option><option>⛰</option><option>☯</option><option>🏹</option><option>🐎</option><option>🗡</option><option>🏮</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Banner color <input id="inp-color" type="color" value="#c2452d"></label>
|
||||
<label>Faction name (optional) <input id="inp-factionname" type="text" maxlength="20" placeholder="House of …"></label>
|
||||
</div>
|
||||
<div class="setup-section">
|
||||
<h3>III. Starting City <span class="cn-small">起兵之地</span></h3>
|
||||
<div id="city-list" class="city-list"></div>
|
||||
<div id="city-desc" class="hint-box"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setup-section">
|
||||
<h3>IV. Difficulty</h3>
|
||||
<div id="diff-list" class="choice-row small"></div>
|
||||
</div>
|
||||
|
||||
<div class="setup-actions">
|
||||
<button class="btn btn-primary" id="btn-begin">Begin the Chronicle</button>
|
||||
<button class="btn" id="btn-back-title">Back</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== PICK FACTION (for standard warlord campaign) ======== -->
|
||||
<div id="faction-screen" class="screen hidden">
|
||||
<div class="setup-scroll">
|
||||
<h2 class="panel-title">Choose a Warlord <span class="cn">选择势力</span></h2>
|
||||
<div id="faction-list" class="faction-grid"></div>
|
||||
<div class="setup-actions">
|
||||
<button class="btn btn-primary" id="btn-faction-go">Lead This Faction</button>
|
||||
<button class="btn" id="btn-back-title2">Back</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== MAIN GAME ======== -->
|
||||
<div id="game-screen" class="screen hidden">
|
||||
<canvas id="gl"></canvas>
|
||||
<div id="labels"></div>
|
||||
|
||||
<!-- Top HUD -->
|
||||
<div id="hud-top">
|
||||
<div class="hud-block hud-date">
|
||||
<span id="hud-season">Spring</span>
|
||||
<b id="hud-date">Winter 193 · Jan</b>
|
||||
</div>
|
||||
<div class="hud-block"><span class="res-ico">◉</span><b id="hud-gold">0</b><span class="delta" id="hud-gold-d"></span></div>
|
||||
<div class="hud-block"><span class="res-ico">🌾</span><b id="hud-food">0</b><span class="delta" id="hud-food-d"></span></div>
|
||||
<div class="hud-block"><span class="res-ico">⚔</span><b id="hud-troops">0</b></div>
|
||||
<div class="hud-block"><span class="res-ico">🏯</span><b id="hud-cities">0</b></div>
|
||||
<div class="hud-block"><span class="res-ico">🎖</span><b id="hud-generals">0</b></div>
|
||||
<div class="hud-block legit" title="Legitimacy — how rightful the realm believes your rule to be"><span class="res-ico">☰</span><b id="hud-legit">0</b></div>
|
||||
<div class="hud-spacer"></div>
|
||||
<button class="icon-btn" id="btn-audio" title="Music / sound">♪</button>
|
||||
<button class="icon-btn" id="btn-save" title="Save game">💾</button>
|
||||
<button class="icon-btn" id="btn-menu" title="Menu">≡</button>
|
||||
<button id="btn-endturn">END TURN <span class="cn-small" style="opacity:.8">下一月</span></button>
|
||||
</div>
|
||||
|
||||
<!-- Left quick info -->
|
||||
<div id="hud-left">
|
||||
<div class="mini-note" id="turn-objective"></div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom tab bar -->
|
||||
<div id="tab-bar">
|
||||
<button class="tab-btn active" data-tab="court">🎖 Generals</button>
|
||||
<button class="tab-btn" data-tab="cities">🏯 Cities</button>
|
||||
<button class="tab-btn" data-tab="armies">🐴 Armies</button>
|
||||
<button class="tab-btn" data-tab="diplomacy">🤝 Diplomacy</button>
|
||||
<button class="tab-btn" data-tab="court-politics">☰ Court</button>
|
||||
<button class="tab-btn" data-tab="journal">📜 Journal</button>
|
||||
<button class="tab-btn" data-tab="chronicle">🖋 Chronicle</button>
|
||||
<button class="tab-btn" data-tab="help">? Help</button>
|
||||
</div>
|
||||
|
||||
<!-- Side panel -->
|
||||
<div id="side-panel" class="hidden">
|
||||
<div id="panel-content"></div>
|
||||
<button class="icon-btn panel-close" id="btn-panel-close">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Selection tooltip card -->
|
||||
<div id="hover-card" class="hidden"></div>
|
||||
|
||||
<!-- Event modal -->
|
||||
<div id="event-modal" class="modal hidden">
|
||||
<div class="event-card">
|
||||
<div class="event-kind" id="event-kind">COURT</div>
|
||||
<h2 id="event-title">—</h2>
|
||||
<div class="event-art" id="event-art"></div>
|
||||
<p id="event-text"></p>
|
||||
<div id="event-choices"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toasts -->
|
||||
<div id="toasts"></div>
|
||||
|
||||
<!-- Turn banner -->
|
||||
<div id="turn-banner" class="hidden"><div id="turn-banner-text"></div></div>
|
||||
|
||||
<!-- Battle overlay -->
|
||||
<div id="battle-overlay" class="hidden">
|
||||
<canvas id="battle-gl"></canvas>
|
||||
<div id="battle-hud">
|
||||
<div class="bh-side bh-left">
|
||||
<div class="bh-name" id="bh-att-name">Attacker</div>
|
||||
<div class="bh-bar"><div id="bh-att-morale"></div></div>
|
||||
<div class="bh-troops" id="bh-att-troops"></div>
|
||||
</div>
|
||||
<div class="bh-center">
|
||||
<div id="bh-round">—</div>
|
||||
<div id="bh-terrain">Plains</div>
|
||||
</div>
|
||||
<div class="bh-side bh-right">
|
||||
<div class="bh-name" id="bh-def-name">Defender</div>
|
||||
<div class="bh-bar"><div id="bh-def-morale"></div></div>
|
||||
<div class="bh-troops" id="bh-def-troops"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="battle-callout"></div>
|
||||
<div id="battle-result" class="hidden"></div>
|
||||
<button id="battle-speed" class="icon-btn">▶▶</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load dialog -->
|
||||
<div id="load-modal" class="modal hidden">
|
||||
<div class="event-card">
|
||||
<h2>Load Campaign</h2>
|
||||
<div id="save-slots"></div>
|
||||
<div style="text-align:center;margin-top:12px"><button class="btn" id="btn-load-close">Close</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Game over / victory -->
|
||||
<div id="end-screen" class="screen hidden">
|
||||
<div class="end-scroll">
|
||||
<h1 id="end-title">THE CHRONICLE OF YOUR DYNASTY</h1>
|
||||
<div id="end-dynasty" class="dynasty-name"></div>
|
||||
<pre id="end-chronicle"></pre>
|
||||
<div id="end-stats"></div>
|
||||
<div class="setup-actions">
|
||||
<button class="btn btn-primary" id="btn-end-title">Return to Title</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="vignette"></div>
|
||||
<div id="loading" class="hidden">LOADING THE REALM…</div>
|
||||
</div>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
// ============================================================
|
||||
// AUDIO — procedural Chinese-inspired score (WebAudio)
|
||||
// Guqin-style Karplus-Strong plucks, taiko drums, wind
|
||||
// ============================================================
|
||||
|
||||
const PENT = [0, 2, 4, 7, 9]; // major pentatonic
|
||||
const BASE = 220; // A3
|
||||
|
||||
export class GameAudio {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.enabled = true;
|
||||
this.musicVol = 0.5;
|
||||
this.sfxVol = 0.7;
|
||||
this.mode = null;
|
||||
this._melodyTimer = null;
|
||||
this._windNodes = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.ctx) return;
|
||||
try {
|
||||
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
this.master = this.ctx.createGain();
|
||||
this.master.gain.value = 0.9;
|
||||
this.master.connect(this.ctx.destination);
|
||||
this.musicGain = this.ctx.createGain();
|
||||
this.musicGain.gain.value = this.musicVol;
|
||||
this.musicGain.connect(this.master);
|
||||
this.sfxGain = this.ctx.createGain();
|
||||
this.sfxGain.gain.value = this.sfxVol;
|
||||
this.sfxGain.connect(this.master);
|
||||
} catch { this.ctx = null; }
|
||||
}
|
||||
|
||||
setMusicVol(v) { this.musicVol = v; if (this.musicGain) this.musicGain.gain.value = v; }
|
||||
setSfxVol(v) { this.sfxVol = v; if (this.sfxGain) this.sfxGain.gain.value = v; }
|
||||
|
||||
// ---------- pluck (Karplus-Strong-ish via noise burst + filtered feedback delay) ----------
|
||||
pluck(freq, when = 0, dur = 1.8, gain = 0.5, bright = 0.35) {
|
||||
if (!this.ctx) return;
|
||||
const t0 = this.ctx.currentTime + when;
|
||||
const sr = this.ctx.sampleRate;
|
||||
const N = Math.floor(sr * dur);
|
||||
const buf = this.ctx.createBuffer(1, N, sr);
|
||||
const data = buf.getChannelData(0);
|
||||
// excitation
|
||||
const period = Math.max(2, Math.floor(sr / freq));
|
||||
for (let i = 0; i < period && i < N; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
// string loop with damping
|
||||
let last = 0;
|
||||
const damp = 0.996 - bright * 0.12;
|
||||
for (let i = period; i < N; i++) {
|
||||
const s = (data[i - period] + data[i - period + 1 >= N ? i - 1 : i - period + 1]) * 0.5 * damp;
|
||||
data[i] = s + last * 0.0001; // tiny body resonance
|
||||
last = s;
|
||||
}
|
||||
const src = this.ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
const g = this.ctx.createGain();
|
||||
g.gain.value = gain;
|
||||
g.gain.setValueAtTime(gain, t0);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
const lp = this.ctx.createBiquadFilter();
|
||||
lp.type = "lowpass"; lp.frequency.value = 2600; lp.Q.value = 0.4;
|
||||
src.connect(lp).connect(g).connect(this.musicGain);
|
||||
src.start(t0);
|
||||
src.stop(t0 + dur + 0.05);
|
||||
}
|
||||
|
||||
noteFreq(deg, oct = 0) {
|
||||
const semis = PENT[((deg % 5) + 5) % 5] + 12 * (oct + Math.floor(deg / 5));
|
||||
return BASE * Math.pow(2, semis / 12);
|
||||
}
|
||||
|
||||
// ---------- melody scheduler ----------
|
||||
playMusic(mode) {
|
||||
this.init();
|
||||
if (!this.ctx || this.mode === mode) return;
|
||||
this.mode = mode;
|
||||
clearInterval(this._melodyTimer);
|
||||
this.stopWind();
|
||||
if (mode === "off") { this.musicGain?.gain.setTargetAtTime(0, this.ctx.currentTime, 0.4); return; }
|
||||
this.musicGain?.gain.setTargetAtTime(this.musicVol, this.ctx.currentTime, 0.3);
|
||||
|
||||
if (mode === "map" || mode === "menu") {
|
||||
this.startWind(0.05);
|
||||
const tempo = mode === "menu" ? 3400 : 2600;
|
||||
const phrase = () => {
|
||||
if (!this.enabled || !this.ctx) return;
|
||||
// gentle wandering pentatonic phrase
|
||||
let deg = Math.floor(Math.random() * 5);
|
||||
const steps = 3 + Math.floor(Math.random() * 4);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
deg += [-2, -1, 1, 1, 2, 3][Math.floor(Math.random() * 6)];
|
||||
const f = this.noteFreq(deg, deg > 6 ? 0 : 1);
|
||||
this.pluck(f, i * (tempo / 1000) * (0.6 + Math.random() * 0.5), 2.4, 0.32 + Math.random() * 0.15, 0.25);
|
||||
}
|
||||
// low drone pluck occasionally
|
||||
if (Math.random() < 0.6) this.pluck(this.noteFreq(0, -1), 0, 4.5, 0.22, 0.05);
|
||||
};
|
||||
phrase();
|
||||
this._melodyTimer = setInterval(phrase, tempo);
|
||||
} else if (mode === "battle") {
|
||||
this.startWind(0.09);
|
||||
// war drums pattern
|
||||
const beat = () => {
|
||||
if (!this.enabled || !this.ctx || this.mode !== "battle") return;
|
||||
this.drum(0, 0.9);
|
||||
setTimeout(() => this.drum(0, 0.5), 380);
|
||||
setTimeout(() => this.drum(0, 0.75), 760);
|
||||
};
|
||||
beat();
|
||||
this._melodyTimer = setInterval(beat, 1900);
|
||||
// tense low strings
|
||||
this.pluck(this.noteFreq(0, -2), 0, 6, 0.3, 0.02);
|
||||
this.pluck(this.noteFreq(3, -2), 2.1, 6, 0.24, 0.02);
|
||||
} else if (mode === "victory") {
|
||||
const seq = [0, 2, 4, 7, 9, 7, 9];
|
||||
seq.forEach((d, i) => this.pluck(this.noteFreq(d, 1), i * 0.16, 2.6, 0.4, 0.3));
|
||||
this.pluck(this.noteFreq(0, -1), 0.1, 5, 0.3, 0.04);
|
||||
this.gong(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- percussion ----------
|
||||
drum(when = 0, gain = 0.8, pitch = 72) {
|
||||
if (!this.ctx) return;
|
||||
const t0 = this.ctx.currentTime + when;
|
||||
const osc = this.ctx.createOscillator();
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(pitch * 2.2, t0);
|
||||
osc.frequency.exponentialRampToValueAtTime(pitch * 0.55, t0 + 0.28);
|
||||
const g = this.ctx.createGain();
|
||||
g.gain.setValueAtTime(gain * 0.9, t0);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.42);
|
||||
// skin noise transient
|
||||
const nb = this.ctx.createBufferSource();
|
||||
const nbuf = this.ctx.createBuffer(1, 2000, this.ctx.sampleRate);
|
||||
const nd = nbuf.getChannelData(0);
|
||||
for (let i = 0; i < nd.length; i++) nd[i] = (Math.random() * 2 - 1) * (1 - i / nd.length);
|
||||
nb.buffer = nbuf;
|
||||
const ng = this.ctx.createGain(); ng.gain.value = gain * 0.35;
|
||||
nb.connect(ng).connect(this.sfxGain);
|
||||
osc.connect(g).connect(this.sfxGain);
|
||||
osc.start(t0); osc.stop(t0 + 0.45);
|
||||
nb.start(t0);
|
||||
}
|
||||
|
||||
gong(when = 0, gain = 0.5) {
|
||||
if (!this.ctx) return;
|
||||
const t0 = this.ctx.currentTime + when;
|
||||
for (const f of [146, 213, 297, 402]) {
|
||||
const o = this.ctx.createOscillator();
|
||||
o.type = "triangle";
|
||||
o.frequency.value = f * (1 + Math.random() * 0.01);
|
||||
const g = this.ctx.createGain();
|
||||
g.gain.setValueAtTime(0, t0);
|
||||
g.gain.linearRampToValueAtTime(gain / 4, t0 + 0.02);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 3.4);
|
||||
o.connect(g).connect(this.sfxGain);
|
||||
o.start(t0); o.stop(t0 + 3.5);
|
||||
}
|
||||
}
|
||||
|
||||
horn(when = 0, gain = 0.4) {
|
||||
if (!this.ctx) return;
|
||||
const t0 = this.ctx.currentTime + when;
|
||||
const o = this.ctx.createOscillator();
|
||||
o.type = "sawtooth";
|
||||
o.frequency.setValueAtTime(98, t0);
|
||||
o.frequency.linearRampToValueAtTime(110, t0 + 0.5);
|
||||
const lp = this.ctx.createBiquadFilter();
|
||||
lp.type = "lowpass"; lp.frequency.value = 900;
|
||||
const g = this.ctx.createGain();
|
||||
g.gain.setValueAtTime(0, t0);
|
||||
g.gain.linearRampToValueAtTime(gain, t0 + 0.12);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 1.4);
|
||||
o.connect(lp).connect(g).connect(this.sfxGain);
|
||||
o.start(t0); o.stop(t0 + 1.5);
|
||||
}
|
||||
|
||||
click() {
|
||||
if (!this.ctx) return;
|
||||
const t0 = this.ctx.currentTime;
|
||||
const o = this.ctx.createOscillator();
|
||||
o.type = "square";
|
||||
o.frequency.value = 1400;
|
||||
const g = this.ctx.createGain();
|
||||
g.gain.setValueAtTime(0.06, t0);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.07);
|
||||
o.connect(g).connect(this.sfxGain);
|
||||
o.start(t0); o.stop(t0 + 0.08);
|
||||
}
|
||||
|
||||
swordClash() {
|
||||
if (!this.ctx) return;
|
||||
const t0 = this.ctx.currentTime;
|
||||
const nb = this.ctx.createBufferSource();
|
||||
const buf = this.ctx.createBuffer(1, 4000, this.ctx.sampleRate);
|
||||
const d = buf.getChannelData(0);
|
||||
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 2);
|
||||
nb.buffer = buf;
|
||||
const bp = this.ctx.createBiquadFilter();
|
||||
bp.type = "bandpass"; bp.frequency.value = 3200; bp.Q.value = 2;
|
||||
const g = this.ctx.createGain(); g.gain.value = 0.25;
|
||||
nb.connect(bp).connect(g).connect(this.sfxGain);
|
||||
nb.start(t0);
|
||||
}
|
||||
|
||||
// ---------- wind ambience ----------
|
||||
startWind(gainV = 0.06) {
|
||||
if (!this.ctx || this._windNodes) return;
|
||||
const sr = this.ctx.sampleRate;
|
||||
const buf = this.ctx.createBuffer(1, sr * 4, sr);
|
||||
const d = buf.getChannelData(0);
|
||||
let v = 0;
|
||||
for (let i = 0; i < d.length; i++) { v += (Math.random() * 2 - 1 - v) * 0.02; d[i] = v * 3; }
|
||||
const src = this.ctx.createBufferSource();
|
||||
src.buffer = buf; src.loop = true;
|
||||
const lp = this.ctx.createBiquadFilter();
|
||||
lp.type = "lowpass"; lp.frequency.value = 480;
|
||||
const g = this.ctx.createGain(); g.gain.value = gainV;
|
||||
src.connect(lp).connect(g).connect(this.musicGain);
|
||||
src.start();
|
||||
this._windNodes = { src, g };
|
||||
}
|
||||
|
||||
stopWind() {
|
||||
if (this._windNodes) {
|
||||
try { this._windNodes.src.stop(); } catch { }
|
||||
this._windNodes = null;
|
||||
}
|
||||
}
|
||||
|
||||
toggleEnabled() {
|
||||
this.enabled = !this.enabled;
|
||||
if (this.ctx) {
|
||||
this.master.gain.setTargetAtTime(this.enabled ? 0.9 : 0, this.ctx.currentTime, 0.1);
|
||||
}
|
||||
return this.enabled;
|
||||
}
|
||||
}
|
||||
|
||||
export const audio = new GameAudio();
|
||||
+489
@@ -0,0 +1,489 @@
|
||||
// ============================================================
|
||||
// BATTLE3D — stylized tactical battle presentation
|
||||
// Plays back a battle record produced by battlesim.js
|
||||
// ============================================================
|
||||
import * as THREE from "./vendor/three.module.js";
|
||||
|
||||
const SOLDIER_ROWS = 8;
|
||||
|
||||
export class BattleScene {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
try {
|
||||
const gl = this.renderer.getContext();
|
||||
const dbg = gl.getExtension("WEBGL_debug_renderer_info");
|
||||
const gpu = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : "";
|
||||
this.lowQuality = /swiftshader|llvmpipe|software/i.test(String(gpu));
|
||||
} catch { this.lowQuality = false; }
|
||||
this.renderer.setPixelRatio(this.lowQuality ? 1 : Math.min(devicePixelRatio, 1.75));
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
this.renderer.toneMappingExposure = 1.15;
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.fog = new THREE.FogExp2(0x191216, 0.016);
|
||||
this.camera = new THREE.PerspectiveCamera(50, 1, 0.5, 600);
|
||||
this.active = false;
|
||||
this.speed = 1;
|
||||
this._time = 0;
|
||||
this._shake = 0;
|
||||
this.resize();
|
||||
window.addEventListener("resize", () => this.resize());
|
||||
}
|
||||
|
||||
resize() {
|
||||
const w = this.canvas.clientWidth || window.innerWidth;
|
||||
const h = this.canvas.clientHeight || window.innerHeight;
|
||||
this.renderer.setSize(w, h, false);
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
// ---------------- world building ----------------
|
||||
buildField(terrainKey, isSiege) {
|
||||
// clear
|
||||
while (this.scene.children.length) this.scene.remove(this.scene.children[0]);
|
||||
this.blocks = [];
|
||||
this.arrows = null;
|
||||
|
||||
const palettes = {
|
||||
plains: { g: 0x9aa267, sky: 0x241a20, fogc: 0x1d151b },
|
||||
hills: { g: 0x8f9760, sky: 0x231a1f, fogc: 0x1c141a },
|
||||
mountain: { g: 0x7e805c, sky: 0x201a22, fogc: 0x191319 },
|
||||
river: { g: 0x8ba065, sky: 0x1e1a22, fogc: 0x181520 },
|
||||
city: { g: 0xa39878, sky: 0x251a18, fogc: 0x201514 },
|
||||
};
|
||||
const pal = palettes[terrainKey] || palettes.plains;
|
||||
this.scene.background = new THREE.Color(pal.sky);
|
||||
this.scene.fog.color = new THREE.Color(pal.fogc);
|
||||
|
||||
// lights
|
||||
const hemi = new THREE.HemisphereLight(0xd8d2c0, 0x33261c, 0.85);
|
||||
this.scene.add(hemi);
|
||||
const sun = new THREE.DirectionalLight(0xffca8a, 1.6);
|
||||
sun.position.set(-40, 55, -25);
|
||||
sun.castShadow = !this.lowQuality;
|
||||
sun.shadow.mapSize.set(1024, 1024);
|
||||
sun.shadow.camera.left = -70; sun.shadow.camera.right = 70;
|
||||
sun.shadow.camera.top = 70; sun.shadow.camera.bottom = -70;
|
||||
this.scene.add(sun);
|
||||
|
||||
// ground
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(240, 240),
|
||||
new THREE.MeshStandardMaterial({ color: pal.g, roughness: 0.95 })
|
||||
);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.receiveShadow = true;
|
||||
this.scene.add(ground);
|
||||
|
||||
// terrain decor
|
||||
const rockMat = new THREE.MeshStandardMaterial({ color: 0x77664f, roughness: 0.95, flatShading: true });
|
||||
const treeMat = new THREE.MeshStandardMaterial({ color: 0x46593a, roughness: 0.9, flatShading: true });
|
||||
const scatter = (geo, mat, n, spread, yBase, sMin, sMax) => {
|
||||
const inst = new THREE.InstancedMesh(geo, mat, n);
|
||||
const m4 = new THREE.Matrix4();
|
||||
const q = new THREE.Quaternion(), e = new THREE.Euler();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = (Math.random() - 0.5) * spread * 2;
|
||||
const z = (Math.random() - 0.5) * spread * 2;
|
||||
const s = sMin + Math.random() * (sMax - sMin);
|
||||
e.set((Math.random() - 0.5) * 0.2, Math.random() * Math.PI * 2, (Math.random() - 0.5) * 0.2);
|
||||
q.setFromEuler(e);
|
||||
m4.compose(new THREE.Vector3(x, yBase, z), q, new THREE.Vector3(s, s, s));
|
||||
inst.setMatrixAt(i, m4);
|
||||
}
|
||||
inst.castShadow = true; inst.receiveShadow = true;
|
||||
this.scene.add(inst);
|
||||
return inst;
|
||||
};
|
||||
const coneGeo = new THREE.ConeGeometry(1, 1, 5); coneGeo.translate(0, 0.5, 0);
|
||||
const treeGeo = new THREE.ConeGeometry(0.8, 2.4, 6); treeGeo.translate(0, 1.2, 0);
|
||||
const treeTrunk = new THREE.CylinderGeometry(0.14, 0.2, 0.8, 4); treeTrunk.translate(0, 0.4, 0);
|
||||
|
||||
if (terrainKey === "mountain") {
|
||||
scatter(coneGeo, rockMat, 60, 90, 0, 4, 14);
|
||||
} else if (terrainKey === "hills") {
|
||||
scatter(coneGeo, rockMat, 26, 90, -0.5, 3, 6);
|
||||
scatter(treeGeo, treeMat, 40, 95, 0, 0.8, 1.8);
|
||||
} else if (terrainKey === "river" || terrainKey === "plains") {
|
||||
scatter(treeGeo, treeMat, terrainKey === "river" ? 46 : 22, 95, 0, 0.7, 1.6);
|
||||
scatter(treeTrunk, rockMat, terrainKey === "river" ? 46 : 22, 95, 0, 0.7, 1.6);
|
||||
}
|
||||
if (terrainKey === "river") {
|
||||
const riv = new THREE.Mesh(new THREE.PlaneGeometry(200, 10),
|
||||
new THREE.MeshStandardMaterial({ color: 0x39536b, roughness: 0.3, metalness: 0.3 }));
|
||||
riv.rotation.x = -Math.PI / 2;
|
||||
riv.position.set(0, 0.06, 34);
|
||||
this.scene.add(riv);
|
||||
}
|
||||
if (isSiege) {
|
||||
// wall behind defender
|
||||
const wallMat = new THREE.MeshStandardMaterial({ color: 0xb5a486, roughness: 0.9 });
|
||||
const wall = new THREE.Mesh(new THREE.BoxGeometry(56, 9 + 3, 2.4), wallMat);
|
||||
wall.position.set(0, 5, -30);
|
||||
wall.castShadow = true;
|
||||
this.scene.add(wall);
|
||||
for (let i = -2; i <= 2; i++) {
|
||||
const tower = new THREE.Mesh(new THREE.BoxGeometry(4, 13, 4), wallMat);
|
||||
tower.position.set(i * 13, 6.5, -30);
|
||||
tower.castShadow = true;
|
||||
this.scene.add(tower);
|
||||
const roof = new THREE.Mesh(new THREE.ConeGeometry(3.2, 2, 4),
|
||||
new THREE.MeshStandardMaterial({ color: 0x8a4438, roughness: 0.7, flatShading: true }));
|
||||
roof.rotation.y = Math.PI / 4;
|
||||
roof.position.set(i * 13, 14, -30);
|
||||
this.scene.add(roof);
|
||||
}
|
||||
}
|
||||
this.isSiege = isSiege;
|
||||
}
|
||||
|
||||
makeSoldierGeo() {
|
||||
// merged little warrior: cone body + sphere head, done manually via two meshes is costly;
|
||||
// use a single stretched octahedron silhouette that reads as a spearman from afar
|
||||
const geo = new THREE.ConeGeometry(0.32, 1.35, 5);
|
||||
geo.translate(0, 0.68, 0);
|
||||
return geo;
|
||||
}
|
||||
|
||||
spawnSide(sideKey, meta, troops) {
|
||||
// split troops into blocks of up to ~600 men
|
||||
const dirZ = sideKey === "atk" ? 1 : -1;
|
||||
const baseZ = sideKey === "atk" ? 16 : -16;
|
||||
const colHex = new THREE.Color(meta.color);
|
||||
const types = Object.entries(troops).filter(([, n]) => n > 0);
|
||||
const totalMen = types.reduce((s, [, n]) => s + n, 0);
|
||||
let blockIdx = 0;
|
||||
const blockDefs = [];
|
||||
for (const [type, men] of types) {
|
||||
const nBlocks = Math.max(1, Math.round(men / 600));
|
||||
for (let b = 0; b < nBlocks; b++) {
|
||||
blockDefs.push({ type, men: men / nBlocks });
|
||||
}
|
||||
}
|
||||
// arrange blocks in ranks facing enemy
|
||||
const cols = Math.min(4, Math.ceil(blockDefs.length / 2));
|
||||
const rows = Math.ceil(blockDefs.length / cols);
|
||||
blockDefs.forEach((bd, i) => {
|
||||
const cx = ((i % cols) - (cols - 1) / 2) * 5.2;
|
||||
const cz = baseZ - dirZ * Math.floor(i / cols) * 5.6;
|
||||
this.spawnBlock(sideKey, bd, cx, cz, dirZ, colHex, totalMen);
|
||||
blockIdx++;
|
||||
});
|
||||
|
||||
// commander figure
|
||||
const cmdCol = new THREE.Color(meta.color);
|
||||
const cmdr = new THREE.Group();
|
||||
const horse = new THREE.Mesh(new THREE.BoxGeometry(1.1, 1.2, 2.6),
|
||||
new THREE.MeshStandardMaterial({ color: 0x3d3229, roughness: 0.75 }));
|
||||
horse.position.y = 1.1; horse.castShadow = true;
|
||||
const rider = new THREE.Mesh(new THREE.ConeGeometry(0.42, 1.7, 6),
|
||||
new THREE.MeshStandardMaterial({ color: cmdCol, roughness: 0.5, emissive: cmdCol.clone().multiplyScalar(0.25) }));
|
||||
rider.position.y = 2.6; rider.castShadow = true;
|
||||
const cape = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.8),
|
||||
new THREE.MeshStandardMaterial({ color: cmdCol.clone().multiplyScalar(0.7), side: THREE.DoubleSide }));
|
||||
cape.position.set(0, 2.2, -0.7); cape.rotation.x = 0.35;
|
||||
const helm = new THREE.Mesh(new THREE.SphereGeometry(0.28, 8, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0xcfa54a, metalness: 0.7, roughness: 0.35 }));
|
||||
helm.position.y = 3.55;
|
||||
cmdr.add(horse, rider, cape, helm);
|
||||
const px = (sideKey === "atk" ? -1 : 1) * 4.5;
|
||||
const pz = baseZ - dirZ * 1.5;
|
||||
cmdr.position.set(px, 0, pz);
|
||||
cmdr.rotation.y = sideKey === "atk" ? 0 : Math.PI;
|
||||
this.scene.add(cmdr);
|
||||
this[sideKey] = { blocks: this.blocks.filter(b => b.side === sideKey), cmdr, homeZ: pz, dirZ, meta };
|
||||
// commander starts slightly behind
|
||||
cmdr.userData.homeX = px; cmdr.userData.homeZ = pz;
|
||||
}
|
||||
|
||||
spawnBlock(sideKey, def, cx, cz, dirZ, colHex, _total) {
|
||||
const rowsN = SOLDIER_ROWS;
|
||||
const colsN = Math.max(3, Math.min(9, Math.round(def.men / 90)));
|
||||
const count = rowsN * colsN;
|
||||
const geo = this.makeSoldierGeo();
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: colHex.clone().multiplyScalar(typeTint(def.type)),
|
||||
roughness: 0.65,
|
||||
emissive: colHex.clone().multiplyScalar(0.08),
|
||||
flatShading: true,
|
||||
});
|
||||
const inst = new THREE.InstancedMesh(geo, mat, count);
|
||||
inst.castShadow = true;
|
||||
inst.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
const soldiers = [];
|
||||
const m4 = new THREE.Matrix4();
|
||||
let k = 0;
|
||||
for (let r = 0; r < rowsN; r++) {
|
||||
for (let c = 0; c < colsN; c++) {
|
||||
const x = cx + (c - (colsN - 1) / 2) * 0.62 + (r % 2) * 0.3;
|
||||
const z = cz + dirZ * r * 0.62;
|
||||
soldiers.push({ x, z, alive: true, fallT: 0, wob: Math.random() * Math.PI * 2 });
|
||||
m4.makeTranslation(x, 0, z);
|
||||
inst.setMatrixAt(k, m4);
|
||||
k++;
|
||||
}
|
||||
}
|
||||
this.scene.add(inst);
|
||||
this.blocks.push({ side: sideKey, inst, soldiers, type: def.type, men: def.men, cx, cz, dirZ, adv: 0 });
|
||||
}
|
||||
|
||||
// ---------------- playback ----------------
|
||||
setup(rec, meta) {
|
||||
this.rec = rec;
|
||||
this.meta = meta;
|
||||
this.buildField(rec.terrain, rec.kind === "siege");
|
||||
this.spawnSide("atk", { ...meta.atk }, rec.initialAtkTroops || {});
|
||||
this.spawnSide("def", { ...meta.def }, rec.initialDefTroops || {});
|
||||
this.phase = "approach";
|
||||
this.phaseT = 0;
|
||||
this.roundIdx = 0;
|
||||
this.done = false;
|
||||
this.speed = 1;
|
||||
this._time = 0;
|
||||
this.camAngle = 0;
|
||||
this.buildArrows();
|
||||
this.fireLight = new THREE.PointLight(0xff7733, 0, 40);
|
||||
this.scene.add(this.fireLight);
|
||||
this.shockRing = null;
|
||||
}
|
||||
|
||||
buildArrows() {
|
||||
const geo = new THREE.CylinderGeometry(0.03, 0.03, 0.9, 4);
|
||||
geo.rotateX(Math.PI / 2);
|
||||
const mat = new THREE.MeshBasicMaterial({ color: 0xe8dcc0 });
|
||||
this.arrowPool = [];
|
||||
const N = 140;
|
||||
this.arrows = new THREE.InstancedMesh(geo, mat, N);
|
||||
this.arrows.frustumCulled = false;
|
||||
const m4 = new THREE.Matrix4().makeScale(0, 0, 0);
|
||||
for (let i = 0; i < N; i++) this.arrows.setMatrixAt(i, m4);
|
||||
this.scene.add(this.arrows);
|
||||
}
|
||||
|
||||
launchArrows(fromSide, n) {
|
||||
if (!this.arrows) return;
|
||||
const src = this[fromSide];
|
||||
const targetZ = src.homeZ - src.dirZ * 26;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const slot = this.arrowPool.find(a => !a.live);
|
||||
if (!slot) break;
|
||||
const b = src.blocks[Math.floor(Math.random() * src.blocks.length)];
|
||||
slot.live = true;
|
||||
slot.t = 0;
|
||||
slot.dur = 0.9 + Math.random() * 0.3;
|
||||
slot.x0 = b.cx + (Math.random() - 0.5) * 6;
|
||||
slot.z0 = b.cz;
|
||||
slot.x1 = (Math.random() - 0.5) * 22;
|
||||
slot.z1 = targetZ + (Math.random() - 0.5) * 6;
|
||||
slot.h = 6 + Math.random() * 4;
|
||||
}
|
||||
}
|
||||
|
||||
applyRoundEffects(round) {
|
||||
// casualties -> knock down soldiers proportionally
|
||||
for (const [sideKey, loss] of [["atk", round.attLoss], ["def", round.defLoss]]) {
|
||||
const side = this[sideKey];
|
||||
if (!side) continue;
|
||||
const totalSoldiers = side.blocks.reduce((s, b) => s + b.soldiers.length, 0);
|
||||
const toDrop = Math.min(totalSoldiers * 0.5, Math.round(loss / 45));
|
||||
for (let i = 0; i < toDrop; i++) {
|
||||
const b = side.blocks[Math.floor(Math.random() * side.blocks.length)];
|
||||
const alive = b.soldiers.filter(s => s.alive);
|
||||
if (alive.length) {
|
||||
const s = alive[Math.floor(Math.random() * alive.length)];
|
||||
s.alive = false; s.fallT = 0.001;
|
||||
}
|
||||
}
|
||||
}
|
||||
// advance attacker slightly
|
||||
for (const b of this.atk?.blocks ?? []) b.advTarget = Math.min(10, (b.advTarget ?? 0) + 0.9);
|
||||
for (const ev of round.events ?? []) this.playEvent(ev);
|
||||
if (round.rout) this.startRout(round.rout);
|
||||
}
|
||||
|
||||
playEvent(ev) {
|
||||
if (!ev.effect) return;
|
||||
const kind = ev.effect.kind;
|
||||
const sideKey = ev.side || "atk";
|
||||
const src = this[sideKey];
|
||||
if (kind === "charge") {
|
||||
for (const b of src.blocks) b.advTarget = (b.advTarget ?? 0) + 3.4;
|
||||
this._shake = 0.7;
|
||||
} else if (kind === "volley") {
|
||||
this.launchArrows(sideKey, 60);
|
||||
} else if (kind === "fire") {
|
||||
this.fireLight.intensity = 260;
|
||||
this._shake = 0.4;
|
||||
} else if (kind === "duel") {
|
||||
// commanders dash together
|
||||
this.duelT = 1.6;
|
||||
this._shake = 0.5;
|
||||
} else if (kind === "guard") {
|
||||
// shield flash: brief brighten of defender blocks
|
||||
for (const b of this.def?.blocks ?? []) b.flash = 0.6;
|
||||
} else if (kind === "morale") {
|
||||
// banner wave boost: hop animation flag
|
||||
src.hopT = 1.0;
|
||||
}
|
||||
this._slowmo = 0.9;
|
||||
}
|
||||
|
||||
startRout(loserSide) {
|
||||
this.routSide = loserSide;
|
||||
this.routT = 0;
|
||||
}
|
||||
|
||||
update(dtReal, hooks) {
|
||||
if (!this.rec || this.done) return;
|
||||
hooks = hooks || this._hooks || {};
|
||||
const dt = Math.min(dtReal, 0.09) * this.speed * (this._slowmo > 0 ? 0.35 : 1);
|
||||
if (this._slowmo > 0) this._slowmo -= dtReal;
|
||||
this._time += dt;
|
||||
const T = this._time;
|
||||
|
||||
// ---- phases ----
|
||||
if (this.phase === "approach") {
|
||||
this.phaseT += dt;
|
||||
for (const b of this.blocks) b.advTarget = Math.min(6, this.phaseT * 2.2);
|
||||
if (this.phaseT > 2.6) { this.phase = "combat"; }
|
||||
} else if (this.phase === "combat") {
|
||||
this.phaseT += dt;
|
||||
const roundDur = 1.15;
|
||||
const wantIdx = Math.floor(this.phaseT / roundDur);
|
||||
while (this.roundIdx <= wantIdx && this.roundIdx < this.rec.rounds.length) {
|
||||
const round = this.rec.rounds[this.roundIdx];
|
||||
this.applyRoundEffects(round);
|
||||
hooks.onRound?.(this.roundIdx, round);
|
||||
this.roundIdx++;
|
||||
}
|
||||
if (this.roundIdx >= this.rec.rounds.length && this.phaseT > (this.rec.rounds.length) * roundDur + 1.2) {
|
||||
this.phase = "epilogue";
|
||||
this.phaseT = 0;
|
||||
}
|
||||
} else if (this.phase === "epilogue") {
|
||||
this.phaseT += dt;
|
||||
if (this.routSide) {
|
||||
const side = this[this.routSide];
|
||||
for (const b of side.blocks) b.advTarget = (b.advTarget ?? 0) - dt * 6;
|
||||
}
|
||||
if (this.phaseT > 2.2 && !this.done) {
|
||||
this.done = true;
|
||||
hooks.onDone?.();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- animate blocks ----
|
||||
for (const b of this.blocks) {
|
||||
if (b.flash > 0) b.flash -= dt;
|
||||
b.adv += ((b.advTarget ?? 0) - b.adv) * Math.min(1, dt * 3);
|
||||
const m4 = new THREE.Matrix4();
|
||||
const q = new THREE.Quaternion();
|
||||
const e = new THREE.Euler();
|
||||
for (let i = 0; i < b.soldiers.length; i++) {
|
||||
const s = b.soldiers[i];
|
||||
if (s.alive) {
|
||||
const marchZ = s.z - b.dirZ * b.adv;
|
||||
e.set(Math.sin(T * 7 + s.wob) * 0.08, 0, 0);
|
||||
q.setFromEuler(e);
|
||||
m4.compose(new THREE.Vector3(s.x, Math.abs(Math.sin(T * 7 + s.wob)) * 0.06, marchZ), q, new THREE.Vector3(1, 1, 1));
|
||||
} else {
|
||||
s.fallT = Math.min(1, s.fallT + dt * 2.2);
|
||||
const f = s.fallT;
|
||||
e.set(b.dirZ * f * Math.PI / 2 * 0.94, s.wob % 1, 0);
|
||||
q.setFromEuler(e);
|
||||
m4.compose(new THREE.Vector3(s.x, 0.12 * f, s.z - b.dirZ * b.adv + f * 0.3), q, new THREE.Vector3(1, 1 - f * 0.2, 1));
|
||||
}
|
||||
b.inst.setMatrixAt(i, m4);
|
||||
}
|
||||
b.inst.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
|
||||
// ---- arrows ----
|
||||
if (this.arrows) {
|
||||
const m4 = new THREE.Matrix4();
|
||||
const q = new THREE.Quaternion();
|
||||
const up = new THREE.Vector3(0, 1, 0);
|
||||
let anyLive = false;
|
||||
for (let i = 0; i < this.arrowPool.length; i++) {
|
||||
const a = this.arrowPool[i];
|
||||
if (!a?.live) continue;
|
||||
anyLive = true;
|
||||
a.t += dt;
|
||||
const t = a.t / a.dur;
|
||||
if (t >= 1) { a.live = false; m4.makeScale(0, 0, 0); this.arrows.setMatrixAt(i, m4); continue; }
|
||||
const x = a.x0 + (a.x1 - a.x0) * t;
|
||||
const z = a.z0 + (a.z1 - a.z0) * t;
|
||||
const y = 0.4 + Math.sin(t * Math.PI) * a.h;
|
||||
const dir = new THREE.Vector3(a.x1 - a.x0, 0, a.z1 - a.z0).normalize();
|
||||
q.setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(dir.x, Math.cos(t * Math.PI) * 0.8, dir.z).normalize());
|
||||
m4.compose(new THREE.Vector3(x, y, z), q, new THREE.Vector3(1, 1, 1));
|
||||
this.arrows.setMatrixAt(i, m4);
|
||||
}
|
||||
if (!anyLive) { /* keep zeroed */ }
|
||||
this.arrows.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
|
||||
// ---- fire flicker ----
|
||||
if (this.fireLight) {
|
||||
this.fireLight.intensity *= Math.pow(0.5, dt * 2.2);
|
||||
if (this.fireLight.intensity > 2) {
|
||||
this.fireLight.position.set((Math.random() - 0.5) * 20, 3, (Math.random() - 0.5) * 20);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- duel choreography ----
|
||||
if (this.duelT > 0) {
|
||||
this.duelT -= dt;
|
||||
const mid = (this.atk.cmdr.position.z + this.def.cmdr.position.z) / 2;
|
||||
for (const sk of ["atk", "def"]) {
|
||||
const c = this[sk].cmdr;
|
||||
const pull = clamp01(1.6 - this.duelT) ;
|
||||
const tz = mid + (sk === "atk" ? 1.6 : -1.6);
|
||||
c.position.z += (tz - c.position.z) * Math.min(1, dt * 4);
|
||||
c.position.x += ((sk === "atk" ? -1 : 1) * (4.5 - pull * 3.4) - c.position.x) * Math.min(1, dt * 4);
|
||||
}
|
||||
} else if (this.atk && this.def) {
|
||||
for (const sk of ["atk", "def"]) {
|
||||
const c = this[sk].cmdr;
|
||||
c.position.x += ((sk === "atk" ? -1 : 1) * 4.5 - c.position.x) * Math.min(1, dt * 2);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- rout flee ----
|
||||
if (this.routSide && this.routT !== null) {
|
||||
this.routT += dt;
|
||||
const side = this[this.routSide];
|
||||
for (const b of side.blocks) {
|
||||
// soldiers turn and run handled by adv negative above
|
||||
}
|
||||
}
|
||||
|
||||
// ---- camera ----
|
||||
this.camAngle += dt * 0.05;
|
||||
const focus = new THREE.Vector3(0, 1.5, this.phase === "approach" ? 4 : 0);
|
||||
const dist = this.phase === "approach" ? 34 : 42;
|
||||
const shake = this._shake > 0 ? (this._shake *= Math.pow(0.02, dtReal), (Math.random() - 0.5) * this._shake) : 0;
|
||||
const cx = Math.sin(this.camAngle) * dist * 0.35;
|
||||
const cz = focus.z + dist * 0.92;
|
||||
this.camera.position.lerp(new THREE.Vector3(cx + shake, 17 + shake, cz), Math.min(1, dt * 2));
|
||||
this.camera.lookAt(focus.x + shake, 2, focus.z - 2);
|
||||
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
}
|
||||
|
||||
function typeTint(type) {
|
||||
switch (type) {
|
||||
case "spear": return 0.85;
|
||||
case "sword": return 1.0;
|
||||
case "bow": return 1.12;
|
||||
case "xb": return 1.05;
|
||||
case "cav": return 0.9;
|
||||
case "hcav": return 0.78;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
// ============================================================
|
||||
// BATTLE SIMULATION — pure round-based tactical resolution.
|
||||
// Produces a replayable record consumed by the battle scene.
|
||||
// ============================================================
|
||||
import { UNIT_TYPES, COUNTER, FORMATIONS } from "./data.js";
|
||||
import { rand, randInt, clamp, chance } from "./state.js";
|
||||
|
||||
export const TERRAIN_MODS = {
|
||||
plains: { name: "Plains", icon: "🌾", cav: 1.2, bow: 1.1, spear: 1.0 },
|
||||
hills: { name: "Hills", icon: "⛰", cav: 0.9, bow: 1.05, spear: 1.05 },
|
||||
mountain: { name: "Mountains",icon: "🏔", cav: 0.6, bow: 0.9, spear: 1.15, defBonus: 1.25 },
|
||||
river: { name: "Riverbank",icon: "🌊", cav: 0.75, bow: 1.0, spear: 0.95 },
|
||||
city: { name: "City Streets", icon: "🏯", cav: 0.7, bow: 0.85, spear: 1.1 },
|
||||
};
|
||||
|
||||
const TYPES = Object.keys(UNIT_TYPES);
|
||||
|
||||
function troopCount(troops) { return TYPES.reduce((s, t) => s + (troops[t] || 0), 0); }
|
||||
|
||||
function composition(troops) {
|
||||
const total = Math.max(1, troopCount(troops));
|
||||
const comp = {};
|
||||
for (const t of TYPES) comp[t] = (troops[t] || 0) / total;
|
||||
return comp;
|
||||
}
|
||||
|
||||
// weighted counter multiplier of my composition vs theirs
|
||||
function counterMul(myComp, theirComp, form) {
|
||||
let mul = 1;
|
||||
for (const mt of TYPES) {
|
||||
let vs = 0;
|
||||
for (const tt of TYPES) vs += (COUNTER[mt]?.[tt] ?? 1) * theirComp[tt];
|
||||
mul += myComp[mt] * (vs - 1);
|
||||
}
|
||||
// formation interactions
|
||||
if (form === "spearwall") mul *= 1.15; // anti-cav baked into stance below too
|
||||
return mul;
|
||||
}
|
||||
|
||||
function powerOf(side, otherSide, round, terrainKey) {
|
||||
const { troops, gen, formation, stance } = side;
|
||||
const total = troopCount(troops);
|
||||
if (total <= 0) return 0;
|
||||
const tm = TERRAIN_MODS[terrainKey] || TERRAIN_MODS.plains;
|
||||
const myComp = composition(troops);
|
||||
const theirComp = composition(otherSide.troops);
|
||||
let pow = 0;
|
||||
for (const t of TYPES) {
|
||||
const n = troops[t] || 0;
|
||||
if (!n) continue;
|
||||
const ut = UNIT_TYPES[t];
|
||||
let m = ut.atk / 25;
|
||||
if (t === "cav" || t === "hcav") m *= tm.cav;
|
||||
if (t === "bow" || t === "xb") m *= tm.bow;
|
||||
if (t === "spear") m *= tm.spear;
|
||||
pow += (n / 100) * ut.atk * 0.5 * m;
|
||||
}
|
||||
pow *= counterMul(myComp, theirComp, formation);
|
||||
|
||||
// formation mods
|
||||
const fm = FORMATIONS[formation]?.mods || {};
|
||||
pow *= fm.atkMul || 1;
|
||||
if (fm.burst && round <= 3) pow *= fm.burst;
|
||||
if (fm.ramp) pow *= Math.pow(fm.ramp, round);
|
||||
if (fm.ranged && (myComp.bow + myComp.xb) > 0.3) pow *= fm.ranged;
|
||||
|
||||
// stance
|
||||
pow *= stance === "assault" ? 1.15 : stance === "defensive" ? 0.85 : 1;
|
||||
|
||||
// commander
|
||||
if (gen) {
|
||||
pow *= 1 + (gen.st.ldr * 0.7 + gen.st.war * 0.3) / 300;
|
||||
if (gen.traits.includes("brave")) pow *= 1.06;
|
||||
if (gen.traits.includes("vanguard")) pow *= round <= 2 ? 1.18 : 1.03;
|
||||
if (gen.traits.includes("impulsive")) pow *= 0.88 + rand() * 0.3;
|
||||
if (gen.wounded > 0) pow *= 0.8;
|
||||
}
|
||||
// fatigue
|
||||
pow *= Math.max(0.55, 1 - round * 0.02);
|
||||
return pow;
|
||||
}
|
||||
|
||||
export function simulateBattle(cfg) {
|
||||
const atk = { ...cfg.atk, morale0: startMorale(cfg.atk.gen), buffs: {}, burn: 0 };
|
||||
const def = { ...cfg.def, morale0: startMorale(cfg.def.gen, cfg.def.walls || 0), buffs: {}, burn: 0 };
|
||||
const terrainKey = cfg.terrain || "plains";
|
||||
const rounds = [];
|
||||
let winner = null;
|
||||
let aMorale = atk.morale0, dMorale = def.morale0;
|
||||
let aTroops = { ...atk.troops }, dTroops = { ...def.troops };
|
||||
let skillCdA = randInt(1, 2), skillCdD = randInt(1, 2);
|
||||
const eventsAll = [];
|
||||
|
||||
const S = {
|
||||
atk: { gen: atk.gen, name: atk.name, faction: atk.faction, formation: atk.formation, stance: atk.stance },
|
||||
def: { gen: def.gen, name: def.name, faction: def.faction, formation: def.formation, stance: def.stance },
|
||||
};
|
||||
|
||||
for (let round = 1; round <= 24 && !winner; round++) {
|
||||
const events = [];
|
||||
// --- hero skills ---
|
||||
if (atk.gen && skillCdA <= 0 && chance(0.16 + atk.gen.st.cha / 500)) {
|
||||
const ev = triggerSkill(atk, def, aTroops, dTroops, () => aMorale, terrainKey);
|
||||
events.push(ev); eventsAll.push({ round, side: "atk", ...ev });
|
||||
aMorale = ev.moraleSelf != null ? ev.moraleSelf : aMorale;
|
||||
dMorale += (ev.moraleHit || 0);
|
||||
if (ev.duelResult != null) {
|
||||
if (ev.duelResult === "win") dMorale -= 28; else aMorale -= 14;
|
||||
}
|
||||
skillCdA = 4;
|
||||
}
|
||||
if (def.gen && skillCdD <= 0 && chance(0.13 + def.gen.st.cha / 550)) {
|
||||
const ev = triggerSkill(def, atk, dTroops, aTroops, () => dMorale, terrainKey);
|
||||
ev.side = "def"; events.push(ev); eventsAll.push({ round, side: "def", ...ev });
|
||||
dMorale = ev.moraleSelf != null ? ev.moraleSelf : dMorale;
|
||||
aMorale += (ev.moraleHit || 0);
|
||||
if (ev.duelResult != null) {
|
||||
if (ev.duelResult === "win") aMorale -= 28; else dMorale -= 14;
|
||||
}
|
||||
skillCdD = 4;
|
||||
}
|
||||
skillCdA--; skillCdD--;
|
||||
|
||||
// --- combat power ---
|
||||
const A = { troops: aTroops, gen: atk.gen, formation: atk.formation, stance: atk.stance };
|
||||
const Df = { troops: dTroops, gen: def.gen, formation: def.formation, stance: def.stance };
|
||||
const wallMul = 1 + (cfg.def.walls || 0) * 0.17;
|
||||
let rawA = powerOf(A, Df, round, terrainKey) * (0.88 + rand() * 0.24);
|
||||
let rawD = powerOf(Df, A, round, terrainKey) * wallMul * (0.88 + rand() * 0.24);
|
||||
|
||||
// defensive formations
|
||||
const dfm = FORMATIONS[def.formation]?.mods || {};
|
||||
const afm = FORMATIONS[atk.formation]?.mods || {};
|
||||
rawD *= dfm.defMul || 1;
|
||||
rawA *= afm.defMul || 1;
|
||||
if ((afm.antiCav || 0) > 0) {
|
||||
const cavShare = (dTroops.cav || 0) + (dTroops.hcav || 0);
|
||||
rawA *= 1 + Math.min(0.35, (cavShare / Math.max(1, troopCount(dTroops))) * afm.antiCav * 0.5);
|
||||
}
|
||||
|
||||
// burns from fire skills
|
||||
if (atk.burn > 0) { rawA *= 1 + atk.burn; atk.burn = Math.max(0, atk.burn - 0.5); }
|
||||
if (def.burn > 0) { rawD *= 1 + def.burn; def.burn = Math.max(0, def.burn - 0.5); }
|
||||
|
||||
// convert to casualties (kills scale ~ dmg/45)
|
||||
const aLossN = Math.round(rawD / 42 * (10 + randInt(0, 6)));
|
||||
const dLossN = Math.round(rawA / 42 * (10 + randInt(0, 6)));
|
||||
|
||||
const aBefore = troopCount(aTroops), dBefore = troopCount(dTroops);
|
||||
applyLosses(aTroops, aLossN);
|
||||
applyLosses(dTroops, dLossN);
|
||||
const aAfter = troopCount(aTroops), dAfter = troopCount(dTroops);
|
||||
|
||||
// morale swings
|
||||
const aLossPct = aBefore ? (aBefore - aAfter) / aBefore : 0;
|
||||
const dLossPct = dBefore ? (dBefore - dAfter) / dBefore : 0;
|
||||
aMorale += dLossPct * 160 - aLossPct * 200;
|
||||
dMorale += aLossPct * 160 - dLossPct * 200;
|
||||
// defensive stances bleed less morale
|
||||
if (atk.stance === "defensive") aMorale += aLossPct * 40;
|
||||
if (def.stance === "defensive") dMorale += dLossPct * 40;
|
||||
// ironwill / cautious
|
||||
if (atk.gen?.traits.includes("ironwill")) aMorale += 1.5;
|
||||
if (def.gen?.traits.includes("ironwill")) dMorale += 1.5;
|
||||
|
||||
rounds.push({
|
||||
round,
|
||||
attLoss: aBefore - aAfter, defLoss: dBefore - dAfter,
|
||||
attMorale: Math.round(clamp(aMorale, 0, 120)), defMorale: Math.round(clamp(dMorale, 0, 120)),
|
||||
attTroops: aAfter, defTroops: dAfter,
|
||||
events,
|
||||
});
|
||||
|
||||
if (dAfter <= 0 || dMorale <= 0) { winner = "atk"; if (dAfter > 0) rounds[rounds.length - 1].rout = "def"; }
|
||||
else if (aAfter <= 0 || aMorale <= 0) { winner = "def"; if (aAfter > 0) rounds[rounds.length - 1].rout = "atk"; }
|
||||
}
|
||||
if (!winner) winner = troopCount(aTroops) >= troopCount(dTroops) ? "atk" : "def";
|
||||
|
||||
// rout extra losses
|
||||
const lastRound = rounds[rounds.length - 1];
|
||||
if (lastRound?.rout === "def") applyLosses(dTroops, Math.round(troopCount(dTroops) * (0.12 + rand() * 0.15)));
|
||||
if (lastRound?.rout === "atk") applyLosses(aTroops, Math.round(troopCount(aTroops) * (0.12 + rand() * 0.15)));
|
||||
|
||||
// fate of defeated commanders handled by caller using these hints
|
||||
const loserGen = winner === "atk" ? def.gen : atk.gen;
|
||||
let captureChance = 0.32, killChance = 0.07;
|
||||
if (loserGen) {
|
||||
if (loserGen.traits.includes("ironwill")) killChance -= 0.04;
|
||||
if (loserGen.st.war > 90) captureChance -= 0.1; // fights free
|
||||
if (cfg.surrender) captureChance = 0.55;
|
||||
}
|
||||
|
||||
return {
|
||||
winner, terrain: terrainKey,
|
||||
rounds, eventsAll,
|
||||
atkTroopsLeft: aTroops, defTroopsLeft: dTroops,
|
||||
atkLost: cfg.atk.troops ? troopCount(cfg.atk.troops) - troopCount(aTroops) : 0,
|
||||
defLost: cfg.def.troops ? troopCount(cfg.def.troops) - troopCount(dTroops) : 0,
|
||||
captureChance, killChance,
|
||||
meta: { atkName: atk.name, defName: def.name, atkFaction: atk.faction, defFaction: def.faction, walls: cfg.def.walls || 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function startMorale(gen, walls = 0) {
|
||||
let m = 62 + (gen ? gen.st.ldr / 5 : 0) + walls * 5;
|
||||
if (gen?.traits.includes("charismatic")) m += 8;
|
||||
return clamp(m + randInt(-6, 6), 30, 110);
|
||||
}
|
||||
|
||||
function applyLosses(troops, loss) {
|
||||
const types = TYPES.filter(t => (troops[t] || 0) > 0);
|
||||
const total = troopCount(troops);
|
||||
if (!total || loss <= 0) return;
|
||||
let remaining = Math.min(loss, total);
|
||||
// elite units slightly more resilient
|
||||
const weight = t => t === "hcav" ? 0.7 : t === "cav" ? 0.9 : 1;
|
||||
const wsum = types.reduce((s, t) => s + weight(t) * troops[t], 0);
|
||||
for (const t of types) {
|
||||
const share = Math.round(remaining * (weight(t) * troops[t]) / wsum);
|
||||
troops[t] = Math.max(0, troops[t] - share);
|
||||
}
|
||||
// rounding remainder off the biggest unit
|
||||
let diff = troopCount(troops) - (total - remaining);
|
||||
if (diff !== 0) {
|
||||
const big = types.sort((a, b) => troops[b] - troops[a])[0];
|
||||
troops[big] = Math.max(0, troops[big] + diff);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerSkill(side, enemy, myTroops, enemyTroops, getMorale, terrainKey) {
|
||||
const gen = side.gen;
|
||||
const sk = gen.skill || "rally";
|
||||
const base = {
|
||||
type: "skill", skillId: sk, who: gen.name, text: "",
|
||||
};
|
||||
switch (sk) {
|
||||
case "greendragon": case "dragonspear": case "weststorm": case "littleconq": case "chargecall": {
|
||||
const pow = skillPow(gen, 1.6);
|
||||
base.effect = { kind: "charge", dmg: Math.round(troopCount(enemyTroops) * 0.06 * pow) };
|
||||
base.text = `${gen.name} rides down the foe — the line buckles!`;
|
||||
break;
|
||||
}
|
||||
case "volley": case "divinearchery": case "fallingstar": {
|
||||
const pow = skillPow(gen, 1.5);
|
||||
base.effect = { kind: "volley", dmg: Math.round(troopCount(enemyTroops) * 0.05 * pow) };
|
||||
base.text = `Arrows rise like rain at ${gen.name}'s command.`;
|
||||
break;
|
||||
}
|
||||
case "tigerroar": case "thunder": case "vengeance": case "rally": {
|
||||
const pow = sk === "rally" ? 14 : 24;
|
||||
base.effect = { kind: "morale" };
|
||||
base.moraleSelf = clamp(getMorale() + pow + gen.st.cha / 10, 0, 115);
|
||||
base.moraleHit = -(pow / 2);
|
||||
base.text = sk === "rally" ? `${gen.name} steadies the line — banners high!` : `${gen.name}'s roar echoes — the enemy falters!`;
|
||||
break;
|
||||
}
|
||||
case "fireattack": case "ambush": case "ruthlessscheme": {
|
||||
base.effect = { kind: "fire", burn: 0.35 * skillPow(gen, 1.3) };
|
||||
base.text = sk === "ambush" ? `Horns from the flanks — it was a trap!` : `Fire takes the dry grass and spreads along the lines!`;
|
||||
break;
|
||||
}
|
||||
case "skypiercer": case "madox": {
|
||||
const myWar = gen.st.war + randInt(0, 20);
|
||||
const foe = enemy.gen ? enemy.gen.st.war + randInt(0, 20) : 50;
|
||||
base.effect = { kind: "duel", dmg: myWar > foe ? Math.round(troopCount(enemyTroops) * 0.05) : 0 };
|
||||
base.duelResult = myWar > foe ? "win" : "lose";
|
||||
base.text = myWar > foe
|
||||
? `${gen.name} seeks out the enemy champion — and cuts him down before the armies!`
|
||||
: `${gen.name} charges the enemy champion — the duel is fierce, and he gives ground!`;
|
||||
break;
|
||||
}
|
||||
case "eightform": case "patience": case "ironguard": {
|
||||
base.effect = { kind: "guard", mult: 0.7 };
|
||||
base.guardNext = true;
|
||||
base.moraleSelf = clamp(getMorale() + 8, 0, 115);
|
||||
base.text = `${gen.name} sets the formation — an unbreakable wall.`;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
base.effect = { kind: "morale" };
|
||||
base.moraleSelf = clamp(getMorale() + 10, 0, 115);
|
||||
base.text = `${gen.name} rallies the troops!`;
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function skillPow(gen, base) {
|
||||
let p = base * (0.8 + gen.st.war / 250);
|
||||
if (gen.traits.includes("genius")) p *= 1.2;
|
||||
return p;
|
||||
}
|
||||
|
||||
export { troopCount };
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
// ============================================================
|
||||
// WARLORD'S FATE — static game data
|
||||
// Stylized hex-map of China + factions, cities, characters
|
||||
// ============================================================
|
||||
|
||||
export const START_YEAR = 193;
|
||||
export const START_MONTH = 1;
|
||||
|
||||
// ---------- HEX MAP ----------
|
||||
// Each char = one hex tile. '.' water. Lowercase letter = province plains,
|
||||
// uppercase = mountainous hex of that province.
|
||||
// Provinces: l liangzhou, o yongzhou, y youzhou, j jizhou, q qingzhou,
|
||||
// a yanzhou, s sili, x xuzhou, u yuzhou, n jingzhou, h yangzhou, g yizhou
|
||||
export const MAP_ROWS = [
|
||||
"llllllll...................",
|
||||
"lLLlllll.yyyyyyyY..........",
|
||||
"lLLLllll.yyyyyyyYY.........",
|
||||
"llLLLlll.yyyyyyyyY.........",
|
||||
"lllLoooooyyyyyyyYY.........",
|
||||
"llllooooojJJjjjjj..........",
|
||||
"lllloooooojJjjjqqqqQQq.....",
|
||||
"...oooosssjJjjjqqqqQQQq....",
|
||||
"....ooossssaaaaaqqqqQQq....",
|
||||
".....oSSSssaaaaaaqqqqqq....",
|
||||
".gggggoSSSaaaaaaaqqqqq.....",
|
||||
".gggggnnssSaaaaaaqqqqx.....",
|
||||
".gGGggnnnnnaaaaaxxxxxxh....",
|
||||
".ggGGGnnnnuuuuUUxxxxxhhh...",
|
||||
"ggggGGnnnnnuuuuuxxxxhhhhh..",
|
||||
".ggggGnnnnnnuuuuuhhhhhhhh..",
|
||||
".gggggnnnnnnnnnhhhhhhhhhh..",
|
||||
".ggggggNnnnnnnnhhhhhhhhhh..",
|
||||
".ggggggNNnnnnnnhhhhhhhhhh..",
|
||||
".ggggggGNnnnnnn..hhhhhhh...",
|
||||
".gggggggNnnnnnn...hhhh.....",
|
||||
"..gggggGG.nnnn.............",
|
||||
"..gggggG...................",
|
||||
"..gggggG...................",
|
||||
"...gggg....................",
|
||||
"...gggg....................",
|
||||
];
|
||||
|
||||
export const HEX_R = 3.1; // world radius of a hex
|
||||
|
||||
export function hexToWorld(col, row) {
|
||||
const w = Math.sqrt(3) * HEX_R;
|
||||
return [col * w + (row % 2 ? w / 2 : 0), row * 1.5 * HEX_R];
|
||||
}
|
||||
|
||||
// ---------- PROVINCES ----------
|
||||
export const PROVINCES = {
|
||||
l: { name: "Liangzhou", cn: "凉州", tint: 0xc9ae7c, mount: 0.85, desc: "Western corridors. Horse country of hard riders." },
|
||||
o: { name: "Yongzhou", cn: "雍州", tint: 0xc4ad7d, mount: 0.55, desc: "Old Qin lands guarding the Guanzhong plain." },
|
||||
y: { name: "Youzhou", cn: "幽州", cn2: "", tint: 0xc7b489, mount: 0.7, desc: "Northern frontier against the steppe." },
|
||||
j: { name: "Jizhou", cn: "冀州", tint: 0xcdbb8b, mount: 0.45, desc: "The richest northern plains, granary of war." },
|
||||
q: { name: "Qingzhou", cn: "青州", tint: 0xcabb90, mount: 0.4, desc: "Eastern peninsula of salt and hills." },
|
||||
a: { name: "Yanzhou", cn: "兖州", tint: 0xcdbc8e, mount: 0.3, desc: "Central crossroads — whoever holds it threatens the capital." },
|
||||
s: { name: "Sili", cn: "司隶", tint: 0xc6b183, mount: 0.6, desc: "The imperial domain: Luoyang and the passes." },
|
||||
x: { name: "Xuzhou", cn: "徐州", tint: 0xc9bd94, mount: 0.25, desc: "Rich eastern lowlands, fought over by every warlord." },
|
||||
u: { name: "Yuzhou", cn: "豫州", tint: 0xc7b78c, mount: 0.3, desc: "Ancient heartland of the Central Plains." },
|
||||
n: { name: "Jingzhou", cn: "荆州", tint: 0xa8ab77, mount: 0.55, desc: "Vast southern rivers region. Seven commanderies of grain and boats." },
|
||||
h: { name: "Yangzhou", cn: "扬州", tint: 0x9cab7d, mount: 0.5, desc: "Lakeland and rivers below the Yangtze." },
|
||||
g: { name: "Yizhou", cn: "益州", tint: 0x99a674, mount: 0.95, desc: "The basin behind mountains — a nation unto itself." },
|
||||
};
|
||||
export const PROVINCE_LETTERS = Object.keys(PROVINCES);
|
||||
|
||||
// ---------- CITIES ----------
|
||||
// [id, name, cn, province, col, row, basePop(thousands), commerce, fertility]
|
||||
export const CITY_DEFS = [
|
||||
["ji", "Ji", "蓟", "y", 12, 2, 180, 52, 60],
|
||||
["beiping", "Beiping", "北平", "y", 15, 2, 150, 46, 55],
|
||||
["ye", "Ye", "邺", "j", 12, 6, 220, 72, 85],
|
||||
["nanpi", "Nanpi", "南皮", "j", 15, 5, 160, 58, 70],
|
||||
["linzi", "Linzi", "临淄", "q", 17, 7, 170, 60, 62],
|
||||
["beihai", "Beihai", "北海", "q", 21, 8, 130, 50, 55],
|
||||
["puyang", "Puyang", "濮阳", "a", 12, 9, 140, 55, 68],
|
||||
["chenliu", "Chenliu", "陈留", "a", 14, 10, 150, 62, 70],
|
||||
["luoyang", "Luoyang", "洛阳", "s", 9, 9, 300, 88, 60],
|
||||
["hongnong", "Hongnong", "弘农", "s", 8, 11, 90, 40, 48],
|
||||
["changan", "Chang'an", "长安", "o", 6, 8, 280, 84, 65],
|
||||
["anding", "Anding", "安定", "o", 5, 6, 80, 34, 45],
|
||||
["wuwei", "Wuwei", "武威", "l", 2, 3, 100, 42, 50],
|
||||
["tianshui", "Tianshui", "天水", "l", 3, 5, 110, 44, 52],
|
||||
["xiaopei", "Xiaopei", "小沛", "x", 16, 12, 110, 44, 66],
|
||||
["xiapi", "Xiapi", "下邳", "x", 19, 13, 190, 64, 80],
|
||||
["xuchang", "Xuchang", "许昌", "u", 14, 13, 170, 66, 78],
|
||||
["wan", "Wan", "宛", "u", 12, 14, 140, 54, 64],
|
||||
["runan", "Runan", "汝南", "u", 15, 14, 150, 50, 72],
|
||||
["shouchun", "Shouchun", "寿春", "h", 18, 15, 160, 56, 76],
|
||||
["jianye", "Jianye", "建业", "h", 21, 14, 200, 74, 72],
|
||||
["lujiang", "Lujiang", "庐江", "h", 18, 17, 120, 48, 66],
|
||||
["wu", "Wu", "吴", "h", 21, 17, 170, 68, 74],
|
||||
["xiangyang", "Xiangyang", "襄阳", "n", 10, 14, 230, 76, 78],
|
||||
["jiangling", "Jiangling", "江陵", "n", 11, 17, 180, 62, 82],
|
||||
["jiangxia", "Jiangxia", "江夏", "n", 14, 16, 140, 52, 70],
|
||||
["changsha", "Changsha", "长沙", "n", 11, 20, 160, 58, 80],
|
||||
["hanzhong", "Hanzhong", "汉中", "g", 4, 11, 120, 48, 68],
|
||||
["chengdu", "Chengdu", "成都", "g", 3, 15, 240, 80, 92],
|
||||
["zitong", "Zitong", "梓潼", "g", 3, 13, 110, 44, 66],
|
||||
["jiangzhou", "Jiangzhou", "江州", "g", 6, 17, 130, 50, 70],
|
||||
["yunnan", "Yunnan", "云南", "g", 6, 22, 90, 38, 58],
|
||||
];
|
||||
|
||||
// ---------- FACTIONS ----------
|
||||
// personality: hawk | diplomat | turtle | merchant | opportunist | fanatic
|
||||
export const FACTION_DEFS = [
|
||||
{ id: "cao", name: "Cao Cao", title: "Protector of the East", color: "#3b62c8", leader: "caocao", personality: "hawk", desc: "Ruthless genius of the Central Plains. Peerless officers, cold ambition.", startCities: ["chenliu"] },
|
||||
{ id: "liu", name: "Liu Bei", title: "Lord of Benevolence", color: "#43a05e", leader: "liubei", personality: "diplomat", desc: "A sandal-weaver who swears brotherhood with heroes. The people love him.", startCities: ["xiaopei"] },
|
||||
{ id: "sun", name: "Sun Ce", title: "Little Conqueror", color: "#cc4633", leader: "sunce", personality: "hawk", desc: "The Tiger of Jiangdong's son, carving a river realm with bold strokes.", startCities: ["jianye", "wu"] },
|
||||
{ id: "yuan", name: "Yuan Shao", title: "Lord of Four Generations", color: "#8f6ad6", leader: "yuanshao", personality: "hawk", desc: "Greatest house of the age — vast lands, divided court.", startCities: ["ye", "nanpi"] },
|
||||
{ id: "gongsun", name: "Gongsun Zan", title: "White Horse General", color: "#dfe5ee", leader: "gongsunzan", personality: "hawk", desc: "Iron cavalry of the northern frontier.", startCities: ["beiping", "ji"] },
|
||||
{ id: "dong", name: "Dong Zhuo", title: "Tyrant Chancellor", color: "#50606e", leader: "dongzhuo", personality: "fanatic", desc: "He burns what he cannot rule. The Empire hates him — and fears him more.", startCities: ["changan", "luoyang", "hongnong"] },
|
||||
{ id: "ma", name: "Ma Teng", title: "Lion of the West", color: "#dd8030", leader: "mateng", personality: "opportunist", desc: "Descendant of heroes, master of the western horse.", startCities: ["wuwei"] },
|
||||
{ id: "liuzhang", name: "Liu Zhang", title: "Lord of Shu", color: "#2ba3a0", leader: "liuzhang", personality: "turtle", desc: "Gentle master of the rich mountain basin.", startCities: ["chengdu", "zitong", "jiangzhou"] },
|
||||
{ id: "liubiao", name: "Liu Biao", title: "Warden of Jingzhou", color: "#c05a9e", leader: "liubiao", personality: "turtle", desc: "Scholar-lord of the great southern rivers.", startCities: ["xiangyang", "jiangling", "jiangxia"] },
|
||||
{ id: "yuanshu", name: "Yuan Shu", title: "General of the Rear", color: "#cf7052", leader: "yuanshu", personality: "fanatic", desc: "Proud cousin of Yuan Shao. His ambition outstrips his sense.", startCities: ["shouchun"] },
|
||||
{ id: "lubu", name: "Lü Bu", title: "Flying General", color: "#9e2b3c", leader: "lubu", personality: "opportunist", desc: "Matchless in battle. Matchless in treachery. Two fathers already betrayed.", startCities: ["puyang"] },
|
||||
{ id: "tao", name: "Tao Qian", title: "Governor of Xu", color: "#a08b62", leader: "taoqian", personality: "diplomat", desc: "An aging, kindly governor in a land everyone covets.", startCities: ["xiapi"] },
|
||||
{ id: "kong", name: "Kong Rong", title: "Scholar of the North", color: "#7fa8b8", leader: "kongrong", personality: "diplomat", desc: "Descendant of Confucius. Braver with words than armies.", startCities: ["beihai"] },
|
||||
{ id: "zhanglu", name: "Zhang Lu", title: "Master of the Ways", color: "#7c8f4a", leader: "zhanglu", personality: "turtle", desc: "Priest-king of the Hanzhong valley, beloved by his flock.", startCities: ["hanzhong"] },
|
||||
{ id: "hansui", name: "Han Sui", title: "Old Friend of the West",color: "#8a6a42", leader: "hansui", personality: "opportunist", desc: "Ma Teng's sworn brother, holding Tianshui's passes.", startCities: ["tianshui"] },
|
||||
{ id: "huangjin", name: "Yellow Turbans", title: "Remnant Zealots", color: "#c9b93a", leader: "guanhai", personality: "fanatic", desc: "The shattered sect still preaches rebellion in the hills.", startCities: ["linzi", "runan"] },
|
||||
{ id: "neutral", name: "Local Garrison", title: "Imperial Remnant", color: "#8a8a82", leader: null, personality: "turtle", desc: "Militias loyal to a fading throne.", startCities: ["xuchang", "wan", "lujiang", "changsha", "anding", "yunnan"] },
|
||||
];
|
||||
export const PLAYER_SPAWN_CITIES = ["xuchang", "wan", "lujiang", "changsha", "yunnan", "anding"];
|
||||
|
||||
// ---------- TRAITS ----------
|
||||
export const TRAITS = {
|
||||
brave: { name: "Brave", icon: "⚔", desc: "+charge damage, morale holds longer" },
|
||||
cautious: { name: "Cautious", icon: "🛡", desc: "fewer losses when losing, resists ambush" },
|
||||
impulsive: { name: "Impulsive", icon: "⚡", desc: "wild swings in battle — brilliant or reckless" },
|
||||
loyalheart: { name: "Loyal Heart", icon: "❤", desc: "loyalty rarely decays" },
|
||||
ambitious: { name: "Ambitious", icon: "🏔", desc: "demands promotion; loyalty falls without glory" },
|
||||
greedy: { name: "Greedy", icon: "💰", desc: "higher salary, can be bribed by enemies" },
|
||||
honorable: { name: "Honorable", icon: "⛩", desc: "refuses bribes; executing prisoners angers them" },
|
||||
cruel: { name: "Cruel", icon: "🩸", desc: "+siege assault, −public order, inspires fear" },
|
||||
charismatic: { name: "Charismatic", icon: "✨", desc: "boosts nearby troops' morale and recruiting" },
|
||||
genius: { name: "Genius", icon: "☯", desc: "stratagems far more likely to succeed" },
|
||||
arrogant: { name: "Arrogant", icon: "😤", desc: "clashes with peers; hard to advise" },
|
||||
kind: { name: "Kind", icon: "🌸", desc: "+public order wherever stationed" },
|
||||
naval: { name: "River Master", icon: "⛵", desc: "+power in river battles" },
|
||||
cavalry: { name: "Cavalry Expert",icon:"🐎", desc: "cavalry under him hit harder" },
|
||||
vanguard: { name: "Vanguard", icon: "🗡", desc: "leads devastating first assaults" },
|
||||
admin: { name: "Administrator",icon: "📐", desc: "+city tax and construction speed" },
|
||||
engineer: { name: "Engineer", icon: "🔧", desc: "sieges progress faster, walls matter less" },
|
||||
spymaster: { name: "Spy Master", icon: "👁", desc: "espionage missions succeed more often" },
|
||||
scholar: { name: "Scholar", icon: "📖", desc: "academies train officers faster" },
|
||||
ironwill: { name: "Iron Will", icon: "🗿", desc: "never routs early; wounded recovers faster" },
|
||||
};
|
||||
|
||||
// ---------- HERO SKILLS ----------
|
||||
export const SKILLS = {
|
||||
greendragon: { name: "Green Dragon Strike", cn: "青龙偃月", type: "charge", pow: 2.2, text: "cleaves through the enemy line like a crescent moon" },
|
||||
tigerroar: { name: "Tiger Roar", cn: "据水断桥", type: "morale", pow: 26, text: "roars so terribly the enemy line wavers and breaks" },
|
||||
dragonspear: { name: "Dragon Spear", cn: "龙胆亮银", type: "charge", pow: 1.9, text: "rides seven times through enemy ranks, unscathed" },
|
||||
skypiercer: { name: "Sky Piercer", cn: "方天画戟", type: "duel", pow: 3.0, text: "seeks the enemy champion amid the storm of halberds" },
|
||||
fireattack: { name: "Fire Attack", cn: "火计", type: "fire", pow: 1.6, text: "the wind turns, and flame devours the foe" },
|
||||
eightform: { name: "Eight Formations", cn: "八阵图", type: "defense",pow: 0.45,text: "the ranks fold into an unfathomable maze of stones" },
|
||||
patience: { name: "Still Water", cn: "隐忍", type: "defense",pow: 0.3, text: "waits, unmoving, until the enemy spends his fury" },
|
||||
weststorm: { name: "Western Storm", cn: "西凉铁骑", type: "charge", pow: 2.1, text: "leads a tide of horsemen that nothing can stand against" },
|
||||
fallingstar: { name: "Drawn Bow, Falling Star", cn: "百步穿杨", type: "volley", pow: 2.0, text: "draws the great bow — the arrow flies beyond sight and strikes" },
|
||||
ironguard: { name: "Iron Guard", cn: "古之恶来", type: "defense",pow: 0.4, text: "stands alone at the gate and the gate does not fall" },
|
||||
madox: { name: "Mad Ox", cn: "虎痴", type: "duel", pow: 2.4, text: "wrestles bulls bare-handed — men scatter before him" },
|
||||
thunder: { name: "Thunder at the Walls",cn: "威震逍遥津",type:"morale", pow: 22, text: "his name alone drains the courage from the foe" },
|
||||
littleconq: { name: "Little Conqueror", cn: "小霸王", type: "charge", pow: 2.0, text: "breaks the siege lines with sheer joyous fury" },
|
||||
divinearchery:{ name: "Divine Archery", cn: "辕门射戟", type: "volley", pow: 1.7, text: "pierces the small branch at a hundred paces — both armies stare" },
|
||||
ruthlessscheme:{ name: "Ruthless Scheme", cn: "毒士", type: "fire", pow: 1.5, text: "whispers one sentence, and the battlefield turns" },
|
||||
vengeance: { name: "Vengeance", cn: "恩怨分明", type: "morale", pow: 18, text: "points at the traitor's banner — the soldiers howl" },
|
||||
rally: { name: "Rally", cn: "激励", type: "morale", pow: 12, text: "raises the standard and steadies the line" },
|
||||
volley: { name: "Arrow Storm", cn: "万箭齐发", type: "volley", pow: 1.4, text: "a thousand arrows darken the sky" },
|
||||
chargecall: { name: "Charge!", cn: "冲锋", type: "charge", pow: 1.5, text: "sounds the horns and rides down the wavering foe" },
|
||||
ambush: { name: "Ambush", cn: "埋伏", type: "fire", pow: 1.45,text: "signals from the tall grass — the trap closes" },
|
||||
};
|
||||
|
||||
// ---------- GENERALS ----------
|
||||
// n,f faction,t title,b birth,st [ldr,war,int,pol,cha],tr traits,sk skill
|
||||
export const GENERAL_DEFS = [
|
||||
// --- Cao Cao's camp ---
|
||||
{ n: "Cao Cao", f: "cao", t: "Warlord of Chenliu", b: 155, st: [96, 84, 92, 94, 90], tr: ["genius", "ambitious", "arrogant"], sk: "ruthlessscheme" },
|
||||
{ n: "Xiahou Dun", f: "cao", t: "Fierce General", b: 157, st: [86, 90, 56, 62, 70], tr: ["brave", "ironwill"], sk: "chargecall" },
|
||||
{ n: "Xiahou Yuan", f: "cao", t: "Swift Cavalry", b: 158, st: [84, 87, 60, 55, 64], tr: ["impulsive", "cavalry"], sk: "chargecall" },
|
||||
{ n: "Cao Ren", f: "cao", t: "Steadfast Defender", b: 168, st: [88, 82, 66, 60, 58], tr: ["cautious", "ironwill"], sk: "ironguard" },
|
||||
{ n: "Dian Wei", f: "cao", t: "Coming Evil", b: 158, st: [70, 96, 32, 20, 40], tr: ["brave", "loyalheart"], sk: "madox" },
|
||||
{ n: "Xu Chu", f: "cao", t: "Mad Tiger", b: 169, st: [68, 95, 36, 22, 44], tr: ["brave", "loyalheart"], sk: "madox" },
|
||||
{ n: "Yu Jin", f: "cao", t: "Disciplined Officer", b: 160, st: [82, 78, 64, 58, 52], tr: ["cautious"], sk: "rally" },
|
||||
{ n: "Xun Yu", f: "cao", t: "King's Advisor", b: 163, st: [52, 30, 96, 98, 84], tr: ["scholar", "honorable", "admin"], sk: "patience" },
|
||||
{ n: "Guo Jia", f: "cao", t: "Young Phoenix Eye", b: 170, st: [46, 24, 97, 74, 80], tr: ["genius", "impulsive"], sk: "fireattack" },
|
||||
// --- Liu Bei's brotherhood ---
|
||||
{ n: "Liu Bei", f: "liu", t: "Lord of Virtue", b: 161, st: [88, 68, 74, 82, 99], tr: ["charismatic", "kind", "loyalheart"], sk: "rally" },
|
||||
{ n: "Guan Yu", f: "liu", t: "Beautiful Beard", b: 160, st: [95, 97, 78, 62, 84], tr: ["brave", "honorable", "loyalheart", "arrogant"], sk: "greendragon" },
|
||||
{ n: "Zhang Fei", f: "liu", t: "Yan Man", b: 165, st: [84, 96, 40, 30, 62], tr: ["brave", "impulsive", "cruel"], sk: "tigerroar" },
|
||||
{ n: "Jian Yong", f: "liu", t: "Wandering Monk-Advisor", b: 158, st: [40, 22, 78, 72, 84], tr: ["kind"], sk: "rally" },
|
||||
{ n: "Mi Zhu", f: "liu", t: "Wealthy Host", b: 162, st: [42, 26, 68, 82, 74], tr: ["admin", "loyalheart"], sk: "rally" },
|
||||
// --- Sun clan ---
|
||||
{ n: "Sun Ce", f: "sun", t: "Little Conqueror", b: 175, st: [90, 91, 74, 62, 92], tr: ["charismatic", "brave", "vanguard"], sk: "littleconq" },
|
||||
{ n: "Zhou Yu", f: "sun", t: "Marquis of Beauty", b: 175, st: [92, 76, 96, 80, 94], tr: ["genius", "naval", "charismatic"], sk: "fireattack" },
|
||||
{ n: "Huang Gai", f: "sun", t: "Old Veteran", b: 148, st: [78, 80, 58, 52, 60], tr: ["ironwill", "naval"], sk: "fireattack" },
|
||||
{ n: "Cheng Pu", f: "sun", t: "Senior General", b: 145, st: [80, 79, 62, 58, 64], tr: ["cautious", "naval"], sk: "rally" },
|
||||
{ n: "Han Dang", f: "sun", t: "River Veteran", b: 150, st: [76, 78, 54, 48, 58], tr: ["naval"], sk: "volley" },
|
||||
{ n: "Zhu Zhi", f: "sun", t: "Governor of Wu", b: 156, st: [70, 62, 66, 76, 66], tr: ["admin"], sk: "rally" },
|
||||
{ n: "Zhang Zhao", f: "sun", t: "Chancellor Material", b: 156, st: [48, 20, 88, 94, 78], tr: ["scholar", "admin"], sk: "patience" },
|
||||
{ n: "Sun Quan", f: "sun", t: "Heir of Jiangdong", b: 182, st: [78, 62, 82, 88, 86], tr: ["kind", "cautious"], sk: "rally", heirOf: "sun" },
|
||||
// --- Yuan Shao's host ---
|
||||
{ n: "Yuan Shao", f: "yuan", t: "Lord of Ye", b: 154, st: [84, 70, 66, 78, 88], tr: ["arrogant", "ambitious"], sk: "chargecall" },
|
||||
{ n: "Yan Liang", f: "yuan", t: "Brave of Hebei", b: 160, st: [78, 91, 42, 30, 48], tr: ["brave", "impulsive"], sk: "chargecall" },
|
||||
{ n: "Wen Chou", f: "yuan", t: "Brave of Hebei", b: 158, st: [76, 90, 40, 28, 46], tr: ["brave", "impulsive"], sk: "chargecall" },
|
||||
{ n: "Zhang He", f: "yuan", t: "Prudent Officer", b: 167, st: [86, 84, 76, 62, 58], tr: ["cautious"], sk: "ambush" },
|
||||
{ n: "Ju Shou", f: "yuan", t: "Loyal Counselor", b: 158, st: [50, 24, 88, 80, 60], tr: ["honorable"], sk: "patience" },
|
||||
{ n: "Tian Feng", f: "yuan", t: "Blunt Strategist", b: 156, st: [48, 26, 90, 74, 52], tr: ["honorable", "arrogant"], sk: "patience" },
|
||||
{ n: "Xu You", f: "yuan", t: "Greedy Advisor", b: 157, st: [44, 22, 89, 66, 62], tr: ["greedy", "arrogant"], sk: "fireattack" },
|
||||
{ n: "Guo Tu", f: "yuan", t: "Court Flatterer", b: 159, st: [40, 18, 78, 70, 66], tr: ["greedy"], sk: "ambush" },
|
||||
// --- Gongsun Zan ---
|
||||
{ n: "Gongsun Zan", f: "gongsun", t: "White Horse General", b: 153, st: [82, 84, 58, 56, 60], tr: ["cavalry", "arrogant"], sk: "weststorm" },
|
||||
{ n: "Zhao Yun", f: "gongsun", t: "Dragon of Changshan", b: 168, st: [92, 98, 76, 58, 82], tr: ["brave", "loyalheart", "kind", "cavalry"], sk: "dragonspear" },
|
||||
{ n: "Tian Kai", f: "gongsun", t: "Frontier Colonel", b: 158, st: [68, 70, 50, 46, 48], tr: [], sk: "volley" },
|
||||
// --- Dong Zhuo's tyrants ---
|
||||
{ n: "Dong Zhuo", f: "dong", t: "Chancellor of State", b: 138, st: [78, 80, 52, 60, 44], tr: ["cruel", "greedy", "ambitious"], sk: "tigerroar" },
|
||||
{ n: "Lü Bu", f: "lubu", t: "Flying General", b: 156, st: [82, 100, 42, 30, 50], tr: ["brave", "ambitious", "impulsive"], sk: "skypiercer", servingDong: true },
|
||||
{ n: "Hua Xiong", f: "dong", t: "Pass Commander", b: 158, st: [74, 88, 44, 32, 44], tr: ["brave"], sk: "chargecall" },
|
||||
{ n: "Li Jue", f: "dong", t: "Bandit General", b: 152, st: [70, 76, 48, 40, 34], tr: ["cruel", "greedy"], sk: "chargecall" },
|
||||
{ n: "Guo Si", f: "dong", t: "Bandit General", b: 153, st: [68, 74, 44, 38, 32], tr: ["cruel", "greedy"], sk: "chargecall" },
|
||||
{ n: "Jia Xu", f: "dong", t: "Poison Scholar", b: 147, st: [56, 30, 98, 84, 62], tr: ["genius", "spymaster"], sk: "ruthlessscheme" },
|
||||
{ n: "Gao Shun", f: "lubu", t: "Camp Rampart", b: 158, st: [84, 88, 58, 40, 42], tr: ["ironwill", "loyalheart"], sk: "ironguard" },
|
||||
{ n: "Zhang Liao", f: "lubu", t: "Quiet Thunder", b: 169, st: [92, 92, 80, 66, 74], tr: ["brave", "loyalheart", "cautious"], sk: "thunder" },
|
||||
{ n: "Chen Gong", f: "lubu", t: "Disillusioned Strategist", b: 161, st: [48, 26, 92, 76, 66], tr: ["honorable"], sk: "ambush" },
|
||||
// --- West ---
|
||||
{ n: "Ma Teng", f: "ma", t: "Lion of Xiliang", b: 145, st: [80, 82, 54, 56, 74], tr: ["brave", "honorable"], sk: "weststorm" },
|
||||
{ n: "Ma Chao", f: "ma", t: "Splendid Stallion", b: 176, st: [88, 97, 56, 34, 72], tr: ["brave", "cavalry", "vanguard", "impulsive"], sk: "weststorm" },
|
||||
{ n: "Pang De", f: "ma", t: "White-Horse Rider", b: 170, st: [82, 90, 58, 44, 52], tr: ["brave", "ironwill"], sk: "chargecall" },
|
||||
{ n: "Ma Dai", f: "ma", t: "Young Cousin", b: 180, st: [74, 78, 62, 50, 56], tr: ["cautious"], sk: "rally" },
|
||||
{ n: "Han Sui", f: "hansui", t: "Elder of the West", b: 144, st: [76, 72, 62, 64, 70], tr: ["cautious"], sk: "rally" },
|
||||
// --- South & river lords ---
|
||||
{ n: "Liu Zhang", f: "liuzhang", t: "Lord of Shu", b: 162, st: [58, 40, 52, 66, 60], tr: ["kind"], sk: "rally" },
|
||||
{ n: "Zhang Ren", f: "liuzhang", t: "Shield of Shu", b: 165, st: [84, 84, 66, 48, 54], tr: ["loyalheart", "cautious"], sk: "ambush" },
|
||||
{ n: "Yan Yan", f: "liuzhang", t: "Old General of Ba", b: 150, st: [78, 76, 58, 54, 60], tr: ["ironwill"], sk: "ironguard" },
|
||||
{ n: "Fa Zheng", f: "liuzhang", t: "Sharp Vengeance", b: 176, st: [50, 24, 94, 78, 64], tr: ["genius"], sk: "vengeance" },
|
||||
{ n: "Liu Biao", f: "liubiao", t: "Warden of Jing", b: 142, st: [64, 44, 74, 84, 80], tr: ["kind", "scholar"], sk: "patience" },
|
||||
{ n: "Kuai Liang", f: "liubiao", t: "Jing Advisor", b: 152, st: [52, 26, 86, 80, 68], tr: ["scholar"], sk: "patience" },
|
||||
{ n: "Cai Mao", f: "liubiao", t: "Naval Commander", b: 155, st: [72, 70, 58, 56, 60], tr: ["naval", "greedy"], sk: "volley" },
|
||||
{ n: "Huang Zhong", f: "neutral", t: "Hidden Dragon of Changsha", b: 148, st: [88, 95, 66, 50, 58], tr: ["brave", "ironwill", "vanguard"], sk: "fallingstar", hidden: true },
|
||||
{ n: "Wei Yan", f: "neutral", t: "Restless Blade", b: 173, st: [82, 88, 58, 42, 50], tr: ["ambitious", "brave"], sk: "ambush", hidden: true },
|
||||
{ n: "Yuan Shu", f: "yuanshu", t: "Lord of Shouchun", b: 155, st: [66, 52, 48, 62, 58], tr: ["arrogant", "greedy", "ambitious"], sk: "chargecall" },
|
||||
{ n: "Ji Ling", f: "yuanshu", t: "Three-Bladed Halberd", b: 160, st: [74, 84, 46, 36, 44], tr: ["brave"], sk: "chargecall" },
|
||||
{ n: "Tao Qian", f: "tao", t: "Kind Governor", b: 132, st: [56, 40, 62, 76, 78], tr: ["kind"], sk: "rally" },
|
||||
{ n: "Cao Bao", f: "tao", t: "Ambitious Colonel", b: 155, st: [62, 60, 44, 48, 50], tr: ["greedy"], sk: "rally" },
|
||||
{ n: "Kong Rong", f: "kong", t: "Confucian Lord", b: 153, st: [48, 30, 82, 78, 84], tr: ["scholar", "kind"], sk: "patience" },
|
||||
{ n: "Taishi Ci", f: "neutral", t: "Wandering Hero", b: 166, st: [84, 90, 70, 54, 72], tr: ["brave", "honorable", "loyalheart"], sk: "divinearchery", hidden: false, freeAgent: true },
|
||||
{ n: "Gan Ning", f: "neutral", t: "Bell-Robbed Pirate", b: 175, st: [80, 88, 66, 40, 62], tr: ["naval", "impulsive", "brave"], sk: "ambush", hidden: true },
|
||||
{ n: "Zhang Lu", f: "zhanglu", t: "Waymaster", b: 148, st: [66, 48, 72, 84, 82], tr: ["kind", "scholar"], sk: "patience" },
|
||||
{ n: "Guan Hai", f: "huangjin", t: "Zealot Chief", b: 155, st: [62, 74, 36, 24, 40], tr: ["brave", "cruel"], sk: "rally" },
|
||||
{ n: "He Yi", f: "huangjin", t: "Zealot Chief", b: 153, st: [58, 70, 34, 22, 38], tr: ["cruel"], sk: "chargecall" },
|
||||
];
|
||||
|
||||
// relationships
|
||||
export const RELATIONS = {
|
||||
sworn: [["Liu Bei", "Guan Yu"], ["Liu Bei", "Zhang Fei"], ["Guan Yu", "Zhang Fei"],
|
||||
["Ma Teng", "Han Sui"], ["Sun Ce", "Zhou Yu"]],
|
||||
rivals: [["Zhuge Liang", "Zhou Yu"], ["Yuan Shao", "Gongsun Zan"], ["Ma Teng", "Dong Zhuo"]],
|
||||
friends: [["Guo Jia", "Cao Cao"], ["Xun Yu", "Cao Cao"], ["Fa Zheng", "Zhang Ren"],
|
||||
["Taishi Ci", "Sun Ce"], ["Zhao Yun", "Liu Bei"]],
|
||||
family: [["Cao Cao", "Xiahou Dun"], ["Cao Cao", "Xiahou Yuan"], ["Cao Cao", "Cao Ren"],
|
||||
["Ma Teng", "Ma Chao"], ["Ma Teng", "Ma Dai"], ["Sun Ce", "Sun Quan"]],
|
||||
};
|
||||
|
||||
// Hidden talents that may be discovered (city -> general definition)
|
||||
export const HIDDEN_TALENTS = [
|
||||
{ n: "Zhuge Liang", f: null, t: "Sleeping Dragon", b: 181, st: [88, 62, 100, 96, 92], tr: ["genius", "loyalheart", "scholar"], sk: "eightform", findCity: "xiangyang", minYear: 199, hint: "a young farmer-scholar who talks about the empire like a chessboard" },
|
||||
{ n: "Pang Tong", f: null, t: "Fledgling Phoenix", b: 179, st: [80, 58, 98, 84, 70], tr: ["genius", "arrogant"], sk: "fireattack", findCity: "jiangling", minYear: 198, hint: "an ugly, brilliant wanderer whom everyone underestimates" },
|
||||
{ n: "Xu Shu", f: null, t: "Single Sword Wanderer", b: 172, st: [76, 72, 90, 70, 76], tr: ["scholar", "brave"], sk: "ambush", findCity: "xuchang", minYear: 195, hint: "a swordsman turned strategist, avenging wrongs along the road" },
|
||||
{ n: "Sima Yi", f: null, t: "Patient Wolf", b: 179, st: [86, 58, 98, 94, 78], tr: ["genius", "cautious", "ambitious"], sk: "patience", findCity: "luoyang", minYear: 201, hint: "a sickly-looking clerk whose eyes miss nothing" },
|
||||
{ n: "Lu Xun", f: null, t: "Young Fire of Jiangdong", b: 183, st: [84, 62, 96, 88, 82], tr: ["genius", "naval", "kind"], sk: "fireattack", findCity: "wu", minYear: 203, hint: "a quiet youth of a great house who reads smoke like scripture" },
|
||||
{ n: "Wang Ping", f: null, t: "Stubborn Veteran", b: 178, st: [78, 80, 60, 52, 50], tr: ["ironwill", "cautious"], sk: "ironguard", findCity: "hanzhong", minYear: 196, hint: "a hill soldier who knows every goat trail in the passes" },
|
||||
];
|
||||
|
||||
// random-name parts for generic recruits
|
||||
export const SURNAME = ["Zhao","Qian","Sun","Li","Zhou","Wu","Zheng","Wang","Feng","Chen","Chu","Wei","Jiang","Shen","Han","Yang","Zhu","Qin","You","Xu","He","Lü","Shi","Zhang","Kong","Cao","Yan","Hua","Jin","Tao","Jiang2","Qi","Xie","Zou","Yu","Bai","Shui","Dou","Zhang2","Pan"];
|
||||
export const GIVEN = ["Ping","An","Kang","Ning","Fu","Gui","Lu","De","Long","Hu","Bao","Shan","Yi","Li","Jun","Jie","Xiong","Hao","Wei","Feng","Yuan","Liang","Zhong","Shi","Bo","Shu","Gong","Wenda","Gongjin","Yunchang","Yide","Zilong","Mingyuan"];
|
||||
|
||||
// ---------- UNITS ----------
|
||||
// atk/def per 100 men; cost gold per 100 recruited; upkeep food/month per 100
|
||||
export const UNIT_TYPES = {
|
||||
spear: { name: "Spearmen", icon: "🔱", atk: 22, def: 26, cost: 60, food: 4, desc: "Cheap and steady. Counters cavalry." },
|
||||
sword: { name: "Swordsmen", icon: "⚔", atk: 26, def: 22, cost: 90, food: 4, desc: "Balanced melee line infantry." },
|
||||
bow: { name: "Archers", icon: "🏹", atk: 24, def: 14, cost: 100, food: 3, desc: "Strikes first rounds from range. Fragile in melee." },
|
||||
xb: { name: "Crossbowmen", icon: "🎯", atk: 32, def: 16, cost: 150, food: 4, desc: "Devastating slow volleys. Weak defense." },
|
||||
cav: { name: "Cavalry", icon: "🐎", atk: 34, def: 24, cost: 180, food: 7, desc: "Fast flankers. Counters archers, dies to spears." },
|
||||
hcav: { name: "Heavy Cavalry",icon: "🦄", atk: 44, def: 34, cost: 300, food: 9, desc: "Shatterhammer of the line. Very expensive." },
|
||||
};
|
||||
|
||||
// counter matrix attacker->defender multiplier
|
||||
export const COUNTER = {
|
||||
spear: { cav: 1.7, hcav: 1.8, spear: 1.0, sword: 0.9, bow: 0.8, xb: 0.8 },
|
||||
sword: { spear: 1.1, sword: 1.0, bow: 1.2, xb: 1.1, cav: 0.8, hcav: 0.7 },
|
||||
bow: { sword: 1.1, spear: 1.1, bow: 1.0, xb: 0.9, cav: 0.6, hcav: 0.5 },
|
||||
xb: { hcav: 1.0, cav: 0.9, spear: 1.2, sword: 1.2, bow: 1.0, xb: 1.0 },
|
||||
cav: { bow: 1.8, xb: 1.9, sword: 1.3, spear: 0.55, cav: 1.0, hcav: 0.8 },
|
||||
hcav: { bow: 2.0, xb: 2.1, sword: 1.5, spear: 0.5, cav: 1.2, hcav: 1.0 },
|
||||
};
|
||||
|
||||
// ---------- BUILDINGS ----------
|
||||
export const BUILDINGS = {
|
||||
farm: { name: "Farm", icon: "🌾", maxLv: 3, cost: lv => 500 + lv * 700, desc: "+food harvest" },
|
||||
market: { name: "Market", icon: "🏮", maxLv: 3, cost: lv => 600 + lv * 800, desc: "+tax income" },
|
||||
barracks: { name: "Barracks", icon: "⚔", maxLv: 3, cost: lv => 600 + lv * 800, desc: "+recruit capacity, troop quality" },
|
||||
wall: { name: "Walls", icon: "🧱", maxLv: 3, cost: lv => 800 + lv * 1000, desc: "+siege defense" },
|
||||
academy: { name: "Academy", icon: "📖", maxLv: 3, cost: lv => 700 + lv * 900, desc: "generals gain XP; unlocks stratagems" },
|
||||
workshop: { name: "Workshop", icon: "🔧", maxLv: 2, cost: lv => 800 + lv * 1000, desc: "cheaper elite troops, siege engines" },
|
||||
granary: { name: "Granary", icon: "🏚", maxLv: 2, cost: lv => 500 + lv * 600, desc: "+food storage, famine protection" },
|
||||
};
|
||||
|
||||
// ---------- FORMATIONS ----------
|
||||
export const FORMATIONS = {
|
||||
balanced: { name: "Balanced Line", icon: "➖", desc: "No bonuses, no weaknesses.", mods: {} },
|
||||
turtle: { name: "Turtle", icon: "🐢", desc: "Defense +30%, damage −15%. Grinds the foe down.", mods: { defMul: 1.3, atkMul: 0.85 } },
|
||||
spearwall: { name: "Spear Wall", icon: "🔱", desc: "Counters cavalry utterly; weak to arrows.", mods: { antiCav: 2.0, vsBow: 1.35 } },
|
||||
cavcharge: { name: "Cavalry Charge",icon: "🐎", desc: "First 3 rounds +50% damage; then fatigue.", mods: { burst: 1.5 } },
|
||||
archerline: { name: "Archer Line", icon: "🏹", desc: "Ranged units +35%; melee weaker.", mods: { ranged: 1.35, melee: 0.85 } },
|
||||
encircle: { name: "Encirclement", icon: "🌀", desc: "+damage each round as the trap closes; risky if losing morale.", mods: { ramp: 1.12 } },
|
||||
};
|
||||
|
||||
// ---------- ORIGINS (custom warlord backgrounds) ----------
|
||||
export const ORIGINS = {
|
||||
governor: { name: "Local Governor", icon: "🏯", desc: "An appointed administrator. +Politics, cities start orderly, cheap buildings.", bonus: { stat: "pol", orderBonus: 15, buildDiscount: 0.15 } },
|
||||
soldier: { name: "Former Soldier", icon: "⚔", desc: "Rose from the ranks. +War, troops cost 20% less, armies +5% morale.", bonus: { stat: "war", troopDiscount: 0.2, moraleBonus: 5 } },
|
||||
noble: { name: "Fallen Noble", icon: "⛩", desc: "Blood of old houses. +Legitimacy 15, start with +1500 gold, marriage offers come easier.", bonus: { legit: 15, gold: 1500, diploBonus: 8 } },
|
||||
bandit: { name: "Bandit Leader", icon: "🐺", desc: "Feared raider. Start with extra troops, +Fear, −Honor. Order harder to keep.", bonus: { fear: 15, honor: -10, extraTroops: 400, orderPenalty: -10 } },
|
||||
merchant: { name: "Merchant Prince", icon: "🏮", desc: "Silver tongue and ledgers. +25% tax, trade deals better, +500 gold.", bonus: { taxBonus: 0.25, gold: 500, tradeBonus: 10 } },
|
||||
scholar: { name: "Wandering Scholar", icon: "📖", desc: "Students everywhere. Free talented retainer, academies cheaper, discover talents easier.", bonus: { stat: "int", academyDiscount: 0.3, findBonus: 0.15 } },
|
||||
exile: { name: "Exiled General", icon: "🎖", desc: "A famous commander fleeing injustice. Start with a veteran sworn friend, +Fame.", bonus: { fame: 15, companion: true } },
|
||||
rebel: { name: "Rebel Firebrand", icon: "🔥", desc: "The peasants rise for you. Recruits cost half, +recruitment, −Legitimacy.", bonus: { recruitDiscount: 0.5, legit: -10, fame: 10 } },
|
||||
};
|
||||
|
||||
export const DIFFICULTIES = {
|
||||
easy: { name: "Shepherd of the People", aiIncome: 0.75, aiAggro: 0.8, playerTax: 1.15, desc: "AI grows slower; forgiving economy." },
|
||||
normal: { name: "Contender", aiIncome: 1.0, aiAggro: 1.0, playerTax: 1.0, desc: "The intended struggle for the realm." },
|
||||
hard: { name: "Vulture of the Age", aiIncome: 1.3, aiAggro: 1.35, playerTax: 0.9, desc: "AI expands aggressively and hunts the weak — including you." },
|
||||
};
|
||||
|
||||
// decorative river polylines (hex col,row)
|
||||
export const RIVERS = [
|
||||
{ name: "Yellow River", pts: [[5,6],[6,7],[7,8],[8,8],[9,9],[10,9],[11,8],[12,7],[13,7],[14,8],[16,8],[18,8],[20,8]] },
|
||||
{ name: "Yangtze", pts: [[3,15],[4,15],[6,15],[8,15],[10,15],[12,15],[13,15],[15,15],[17,16],[19,16],[21,16],[23,17]] },
|
||||
{ name: "Han River", pts: [[9,12],[9,13],[10,14]] },
|
||||
{ name: "Huai River", pts: [[14,13],[16,13],[18,14],[19,15]] },
|
||||
];
|
||||
|
||||
// season names
|
||||
export const SEASONS = ["Winter","Spring","Summer","Autumn"];
|
||||
export function seasonOfMonth(m){ if(m>=2&&m<=4)return"Spring"; if(m>=5&&m<=7)return"Summer"; if(m>=8&&m<=10)return"Autumn"; return"Winter"; }
|
||||
+570
@@ -0,0 +1,570 @@
|
||||
// ============================================================
|
||||
// EVENTS — historical drama, crises, court intrigue.
|
||||
// Events are queued for the player as modal cards; AI-side
|
||||
// history unfolds silently and is written to the chronicle.
|
||||
// ============================================================
|
||||
import * as D from "./data.js";
|
||||
import {
|
||||
G, F, GEN, CITY, rand, randInt, pick, chance, clamp,
|
||||
playerFaction, isPlayerFaction, factionCities, factionGenerals, assignCity,
|
||||
atWar, warKey, declareWar, makePeace, trustBump, getTrust,
|
||||
log, chronicle, uid, totalTroops, makeGeneral, findGen,
|
||||
} from "./state.js";
|
||||
import { provNeighbors } from "./world.js";
|
||||
import {
|
||||
transferGeneral, captureGeneral, killGeneral, collapseFaction,
|
||||
proclaimEmperor, protectEmperor, makeEvent, recruitToGarrison,
|
||||
} from "./sim.js";
|
||||
|
||||
// ---------------- MAIN ROLL ----------------
|
||||
export function rollEvents() {
|
||||
scriptedHistory();
|
||||
worldCrisis();
|
||||
playerCourtEvents();
|
||||
}
|
||||
|
||||
// ---------------- SCRIPTED HISTORY ----------------
|
||||
function scriptedHistory() {
|
||||
const y = G.year, m = G.month;
|
||||
|
||||
// --- Dong Zhuo's fall (Wang Yun's plot) ---
|
||||
if (!G.flags.dongzhuoDead && F("dong").alive && findGen("Dong Zhuo")?.alive && (y > 193 || m >= 5)) {
|
||||
if (chance(0.45)) {
|
||||
G.flags.dongzhuoDead = true;
|
||||
const dong = findGen("Dong Zhuo");
|
||||
killGeneral(dong, "was slain by Lü Bu in the palace courtyard — Wang Yun's plot");
|
||||
// Lü Bu briefly holds Chang'an? Historically he flees; Li Jue takes over.
|
||||
const lijue = findGen("Li Jue");
|
||||
if (lijue && lijue.alive) {
|
||||
F("dong").leader = lijue.id;
|
||||
lijue.isLeader = true;
|
||||
lijue.location = F("dong").capital;
|
||||
for (const g of factionGenerals("dong")) g.loyalty = clamp(g.loyalty + randInt(-8, 8), 0, 100);
|
||||
}
|
||||
panicAll("dong", 10);
|
||||
chronicle(`Dong Zhuo dies under his adopted son's halberd. The tyrant's coalition scatters.`, "epic");
|
||||
if (isPlayerNeighborOf("dong")) {
|
||||
G.pendingEvents.push(makeEvent({
|
||||
kind: "history", art: "🔥",
|
||||
title: "The Tyrant Falls",
|
||||
text: "Word gallops across the passes: Dong Zhuo is dead, cut down in the palace gate by the Flying General himself. Chang'an burns. His officers scatter like startled crows.\n\nEvery warlord in China redraws their maps tonight.",
|
||||
choices: [
|
||||
{ label: "Watch, and sharpen blades", hint: "The chaos continues", effect: { fn: "noop" } },
|
||||
],
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tao Qian yields Xuzhou ---
|
||||
const taoqian = findGen("Tao Qian");
|
||||
if (!G.flags.taoOffered && taoqian?.alive && y >= 194 && chance(0.4)) {
|
||||
G.flags.taoOffered = true;
|
||||
const candidates = Object.values(G.factions).filter(f =>
|
||||
f.alive && f.id !== "tao" && f.id !== "neutral" && f.id !== "huangjin" &&
|
||||
f.cities.some(cid => provNeighbors(CITY(cid).prov).includes("x")));
|
||||
// Liu Bei preferred, then highest honor
|
||||
let heirFac = candidates.find(f => f.id === "liu");
|
||||
if (!heirFac) heirFac = candidates.sort((a, b) => b.honor - a.honor)[0];
|
||||
const xzCities = factionCities("tao").map(c => c.id);
|
||||
// his officers follow the chosen successor
|
||||
if (heirFac) for (const g of factionGenerals("tao").filter(g2 => g2.id !== taoqian.id)) transferGeneral(g, heirFac.id);
|
||||
killGeneral(taoqian, "dies of illness, old and honored"); // collapses tao; cities go neutral
|
||||
if (heirFac) {
|
||||
if (isPlayerFaction(heirFac.id)) {
|
||||
G.pendingEvents.push(makeEvent({
|
||||
kind: "history", art: "🗺",
|
||||
title: "Tao Qian's Legacy",
|
||||
text: `On his deathbed, Governor Tao Qian of Xuzhou names YOU — not his sons — as the man to shelter his people.\n\n"The people of Xu have suffered enough. Lead them."`,
|
||||
choices: [
|
||||
{ label: "Accept Xuzhou with bowed head", hint: `Gain ${xzCities.length} city/cities, +legitimacy, +fame`, effect: { fn: "inherit_cities", from: "tao", legit: 8 } },
|
||||
{ label: "Refuse — it must be a Liu", hint: "+Honor greatly", effect: { fn: "noop_honor" } },
|
||||
],
|
||||
}));
|
||||
} else {
|
||||
for (const cid of xzCities) if (CITY(cid).owner === "neutral" || !CITY(cid).owner) assignCity(cid, heirFac.id);
|
||||
chronicle(`Tao Qian dies, willing Xuzhou to ${F(heirFac.id).name}. The realm is astonished.`, "epic");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Yuan Shu proclaims himself Emperor ---
|
||||
if (!G.flags.yuanshuEmperor && F("yuanshu").alive && y >= 196 && F("yuanshu").cities.length >= 2 && chance(0.35)) {
|
||||
G.flags.yuanshuEmperor = true;
|
||||
F("yuanshu").legitimacy = clamp(F("yuanshu").legitimacy - 25, 0, 100);
|
||||
F("yuanshu").rank = "Emperor";
|
||||
chronicle(`Yuan Shu proclaims HIMSELF Son of Heaven in Shouchun. All China spits at the news.`, "epic");
|
||||
for (const f of Object.values(G.factions)) {
|
||||
if (f.alive && f.id !== "yuanshu" && f.id !== "neutral") {
|
||||
trustBump(f.id, "yuanshu", -50);
|
||||
if (chance(0.4)) declareWar(f.id, "yuanshu", "usurpation");
|
||||
}
|
||||
}
|
||||
if (!isPlayerFaction("yuanshu")) {
|
||||
G.pendingEvents.push(makeEvent({
|
||||
kind: "history", art: "👑",
|
||||
title: "A False Emperor",
|
||||
text: "Yuan Shu has crowned himself Emperor with a forged seal. Even his own officers laugh behind sleeves.\n\nThe coalition gates are opening...",
|
||||
choices: [{ label: "Let the dogs hunt him", hint: "Everyone may now war on Yuan Shu", effect: { fn: "noop" } }],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Protecting the Han Emperor (whoever holds Luoyang) ---
|
||||
if (!G.flags.emperorProtectedBy && CITY("luoyang").owner && CITY("luoyang").owner !== "neutral" && CITY("luoyang").owner !== "huangjin") {
|
||||
const owner = CITY("luoyang").owner;
|
||||
G.flags.emperorProtectedBy = owner;
|
||||
if (isPlayerFaction(owner)) {
|
||||
G.pendingEvents.push(makeEvent({
|
||||
kind: "court", art: "🏯",
|
||||
title: "The Emperor in Your Care",
|
||||
text: "Your soldiers found the young Emperor in a ruined farmhouse, eating coarse grain off a broken table.\n\nHe is yours to guard... or to use.",
|
||||
choices: [
|
||||
{ label: "Protect the Emperor", hint: "+Legitimacy each month, all factions respect you more", effect: { fn: "protect_emperor" } },
|
||||
{ label: "Merely house him quietly", hint: "Small legitimacy gain now", effect: { fn: "emperor_quiet", legit: 5 } },
|
||||
],
|
||||
}));
|
||||
} else {
|
||||
log(`${F(owner).name} takes custody of the Han Emperor.`, "war");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Three Visits (Zhuge Liang) ---
|
||||
if (!G.flags.threeVisits && y >= 200 && findGen("Liu Bei")?.alive && !findGen("Zhuge Liang")) {
|
||||
const liuFac = findGen("Liu Bei").faction;
|
||||
if (F(liuFac)?.alive && F(liuFac).cities.length >= 1) {
|
||||
G.flags.threeVisits = true;
|
||||
const zl = makeGeneral("Zhuge Liang", liuFac, 181, [88, 62, 100, 96, 92], ["genius", "loyalheart", "scholar"], "eightform", "Sleeping Dragon");
|
||||
zl.loyalty = 95; zl.location = F(liuFac).capital;
|
||||
chronicle(`${findGen("Liu Bei").name} calls three times on a thatched hut at Longzhong. Zhuge Liang rises, and sees the whole empire at a glance.`, "epic");
|
||||
if (isPlayerFaction(liuFac)) {
|
||||
G.pendingEvents.push(makeEvent({
|
||||
kind: "history", art: "🐉",
|
||||
title: "The Sleeping Dragon Wakes",
|
||||
text: "Three times you climbed the winding path to Longzhong. The third time, the young scholar was home.\n\nHe unrolled a map of the realm on his knee and said: \"The House of Han cannot be restored by force alone. But stand where three kingdoms meet, and wait for the change of winds...\"",
|
||||
choices: [
|
||||
{ label: "Bow twice, and beg him to come down the mountain", hint: "Zhuge Liang joins you!", effect: { fn: "noop_good" } },
|
||||
],
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Guandu-style clash ---
|
||||
if (!G.flags.guanduDone && F("cao").alive && F("yuan").alive && atWar("cao", "yuan") && y >= 198 && chance(0.3)) {
|
||||
const caoPow = factionTroopCountLocal("cao"), yuanPow = factionTroopCountLocal("yuan");
|
||||
G.flags.guanduDone = true;
|
||||
const winner = caoPow * 1.15 > yuanPow ? "cao" : "yuan"; // Cao Cao's genius edge
|
||||
const loserId = winner === "cao" ? "yuan" : "cao";
|
||||
const xuYou = findGen("Xu You");
|
||||
let flavor = "";
|
||||
if (winner === "cao") {
|
||||
flavor = xuYou?.alive ? " A defector's whisper sends flames through the granaries at Wuchao." : " A midnight raid burns the great granaries.";
|
||||
panicAll("yuan", 15);
|
||||
} else {
|
||||
panicAll("cao", 15);
|
||||
}
|
||||
chronicle(`The Two Rivers clash north of the Yellow River.${flavor} ${F(winner).name} carries the day.`, "epic");
|
||||
for (const g of factionGenerals(loserId)) if (g.traits.includes("cautious")) g.loyalty = clamp(g.loyalty - 10, 0, 100);
|
||||
}
|
||||
|
||||
// --- Red Cliffs-style southern fire ---
|
||||
if (!G.flags.redcliffsDone && y >= 200) {
|
||||
const southIds = ["sun", "liu", "liubiao"];
|
||||
for (const nid of southIds) {
|
||||
if (!F(nid).alive) continue;
|
||||
for (const enemyId of ["cao", "yuan", "dong"]) {
|
||||
if (!atWar(nid, enemyId)) continue;
|
||||
const invaders = Object.values(G.armies).filter(a => a.faction === enemyId && ["n", "h"].includes(a.prov));
|
||||
if (invaders.length >= 2) {
|
||||
G.flags.redcliffsDone = true;
|
||||
const defenderGen = factionGenerals(nid).sort((a, b) => b.st.int - a.st.int)[0];
|
||||
chronicle(`Great ships crowd the Yangtze. ${defenderGen?.name ?? "The defenders"} read the east wind, loose fire ships, and the northern host is ash on the water.`, "epic");
|
||||
// devastating losses to invaders
|
||||
for (const inv of invaders) {
|
||||
for (const t of Object.keys(inv.troops)) inv.troops[t] = Math.round(inv.troops[t] * (0.3 + rand() * 0.25));
|
||||
}
|
||||
const aliveSouth = southIds.filter(x => F(x).alive && x !== nid);
|
||||
if (aliveSouth.length) trustBump(nid, aliveSouth[0], 15);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (G.flags.redcliffsDone) break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lü Bu betrays again ---
|
||||
const lubu = findGen("Lü Bu");
|
||||
if (lubu?.alive && F("lubu").alive && G.turnCount > 6 && chance(0.06)) {
|
||||
// finds a new host or attacks a neighbor
|
||||
if (lubu.faction !== "lubu") {
|
||||
const dest = pick(Object.values(G.factions).filter(f => f.alive && f.id !== lubu.faction && f.id !== "neutral"));
|
||||
transferGeneral(lubu, dest.id);
|
||||
chronicle(`${lubu.name} abandons his third lord without blinking. "Whoever pays best," he shrugs.`, "war");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Yellow Turban resurgence waves ---
|
||||
if (y <= 196 && F("huangjin").alive && chance(0.25)) {
|
||||
const cityId = pick(F("huangjin").cities.length ? F("huangjin").cities : ["linzi"]);
|
||||
const c = CITY(cityId);
|
||||
recruitToGarrison(c, "spear", 400);
|
||||
recruitToGarrison(c, "bow", 150);
|
||||
if (chance(0.4)) log("Yellow scarves stream in from the hills — the sect still burns.", "war");
|
||||
}
|
||||
|
||||
// --- Hua Tuo the wandering physician ---
|
||||
if (chance(0.08)) {
|
||||
const wounded = Object.values(G.generals).filter(g => g.alive && g.wounded > 0);
|
||||
if (wounded.length) {
|
||||
const w = pick(wounded);
|
||||
w.wounded = 0;
|
||||
if (isPlayerFaction(w.faction)) log(`Hua Tuo, the wandering physician, heals ${w.name}.`, "good");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function factionTroopCountLocal(fid) {
|
||||
let n = 0;
|
||||
for (const cid of F(fid).cities) n += totalTroops(CITY(cid).garrison);
|
||||
return n;
|
||||
}
|
||||
function panicAll(fid, amt) {
|
||||
for (const g of factionGenerals(fid)) g.loyalty = clamp(g.loyalty - amt, 0, 100);
|
||||
}
|
||||
function isPlayerNeighborOf(fid) {
|
||||
const pf = playerFaction();
|
||||
for (const cid of pf.cities) {
|
||||
const prov = CITY(cid).prov;
|
||||
for (const n of provNeighbors(prov)) {
|
||||
if (G.provinces[n].cities.some(id => CITY(id).owner === fid)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------- WORLD CRISIS ----------------
|
||||
function worldCrisis() {
|
||||
const r = rand();
|
||||
if (r < 0.06) {
|
||||
// plague
|
||||
const allCities = Object.values(G.cities).filter(c => c.owner !== null);
|
||||
if (!allCities.length) return;
|
||||
const c = pick(allCities);
|
||||
c.pop = Math.round(c.pop * 0.93); c.order = clamp(c.order - 10, 5, 100);
|
||||
log(`Plague sweeps ${c.name}.`, c.owner === G.playerFaction ? "bad" : "war");
|
||||
if (c.owner === G.playerFaction) {
|
||||
G.pendingEvents.push(makeEvent({
|
||||
kind: "crisis", art: "☠",
|
||||
title: "Plague in " + c.name,
|
||||
text: "Carts of the dead pass through the market at dusk. The physicians demand action; the merchants demand trade.",
|
||||
choices: [
|
||||
{ label: "Quarantine the quarters (−300 gold)", hint: "Order recovers faster", effect: { fn: "plague_quarantine", city: c.id } },
|
||||
{ label: "Trust in Heaven", hint: "Free, but risky", effect: { fn: "noop" } },
|
||||
],
|
||||
}));
|
||||
}
|
||||
} else if (r < 0.11) {
|
||||
// locusts / flood
|
||||
const facs = Object.values(G.factions).filter(f => f.alive);
|
||||
const f = pick(facs);
|
||||
if (!f.cities.length) return;
|
||||
const c = CITY(pick(f.cities));
|
||||
f.food = Math.max(0, f.food - Math.round(500 + c.pop * 2));
|
||||
if (f.isPlayer) log(`Locusts strip the fields near ${c.name}. Food stores suffer.`, "bad");
|
||||
} else if (r < 0.16) {
|
||||
// bandit uprising
|
||||
const f = pick(Object.values(G.factions).filter(x => x.alive && x.cities.length));
|
||||
if (!f) return;
|
||||
const c = CITY(pick(f.cities));
|
||||
c.order = clamp(c.order - 12, 5, 100);
|
||||
if (f.isPlayer) log(`Bandits grow bold in the hills above ${c.name}.`, "bad");
|
||||
} else if (r < 0.22) {
|
||||
// merchant caravan offers deal
|
||||
const pf = playerFaction();
|
||||
if (pf.alive) {
|
||||
G.pendingEvents.push(makeEvent({
|
||||
kind: "opportunity", art: "🐫",
|
||||
title: "A Silk Road Caravan",
|
||||
text: "Traders from the Western Regions arrive with jade, horses, and gossip about every court in China.",
|
||||
choices: [
|
||||
{ label: "Buy war horses (−800 gold, +300 cavalry in capital)", effect: { fn: "caravan_horses", cost: 800 }, hint: "Requires gold" },
|
||||
{ label: "Sell them grain (+600 gold)", hint: "−800 food", effect: { fn: "caravan_grain" } },
|
||||
{ label: "Send them away", effect: { fn: "noop" } },
|
||||
],
|
||||
}));
|
||||
}
|
||||
} else if (r < 0.27) {
|
||||
// refugees
|
||||
const pf = playerFaction();
|
||||
if (pf.alive && pf.cities.length) {
|
||||
const c = CITY(pick(pf.cities));
|
||||
G.pendingEvents.push(makeEvent({
|
||||
kind: "opportunity", art: "🏕",
|
||||
title: "Refugees at the Gate",
|
||||
text: `Thousands flee the wars, arriving starving before ${c.name}. They could till your fields — or fill them with graves.`,
|
||||
choices: [
|
||||
{ label: "Open the gates (−700 food)", hint: "+population, +order, +honor", effect: { fn: "refugees_accept", city: c.id } },
|
||||
{ label: "Turn them away", hint: "They will remember", effect: { fn: "noop" } },
|
||||
],
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- PLAYER COURT ----------------
|
||||
function playerCourtEvents() {
|
||||
const pf = playerFaction();
|
||||
if (!pf.alive) return;
|
||||
|
||||
// ambitious general demands command
|
||||
if (chance(0.18)) {
|
||||
const gens = factionGenerals(pf.id).filter(g => !g.isLeader && g.traits.includes("ambitious"));
|
||||
if (gens.length) {
|
||||
const g = pick(gens);
|
||||
if (g.loyalty < 60 || chance(0.5)) {
|
||||
G.pendingEvents.push(makeEvent({
|
||||
kind: "court", art: "🏔",
|
||||
title: `${g.name}'s Ambition`,
|
||||
text: `${g.name} stands in your hall, helmet under arm. His victories have made him famous — perhaps more famous than his lord.\n\n"My lord, give me a province worthy of my sword. Or watch what ambition denied becomes."`,
|
||||
choices: [
|
||||
{ label: "Grant him a title and honors (−500 gold)", hint: "+20 loyalty, feeds ambition safely", effect: { fn: "ambition_grant", gen: g.id, cost: 500 } },
|
||||
{ label: "Promote another over him", hint: "Dangerous — he will not forget this insult", effect: { fn: "ambition_insult", gen: g.id } },
|
||||
{ label: "Send spies into his household", hint: "Learn his heart… probably", effect: { fn: "ambition_spy", gen: g.id } },
|
||||
{ label: "Ignore the request", hint: "Loyalty falls", effect: { fn: "ambition_ignore", gen: g.id } },
|
||||
],
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// marriage offer
|
||||
if (chance(0.1) && pf.cities.length >= 2) {
|
||||
const others = Object.values(G.factions).filter(f => f.alive && f.id !== pf.id && f.id !== "neutral" && f.id !== "huangjin" && !atWar(pf.id, f.id));
|
||||
if (others.length) {
|
||||
const other = pick(others);
|
||||
const trust = getTrust(pf.id, other.id);
|
||||
if (trust > -20) {
|
||||
G.pendingEvents.push(makeEvent({
|
||||
kind: "diplomacy", art: "🏮",
|
||||
title: `A Marriage Proposal from ${other.name}`,
|
||||
text: `An envoy arrives with red silk and a genealogy. ${other.name} offers a daughter of their house in marriage — binding two banners with one ceremony.`,
|
||||
choices: [
|
||||
{ label: "Accept the match", hint: "Strong alliance + trust", effect: { fn: "marry", fac: other.id } },
|
||||
{ label: "Decline politely", hint: "Slight trust loss", effect: { fn: "marry_decline", fac: other.id } },
|
||||
],
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wandering hero
|
||||
if (chance(0.08)) {
|
||||
const freeAgents = Object.values(G.generals).filter(g => g.freeAgent && g.alive && !g.hidden);
|
||||
const cap = pf.capital ? CITY(pf.capital) : null;
|
||||
if (cap) {
|
||||
if (freeAgents.length && chance(0.6)) {
|
||||
const unoffered = freeAgents.filter(x => !x.offeredOnce);
|
||||
const hero = pick(unoffered.length ? unoffered : freeAgents);
|
||||
if (hero) hero.offeredOnce = true;
|
||||
G.pendingEvents.push(makeEvent({
|
||||
kind: "opportunity", art: "🍶",
|
||||
title: `${hero.name} Comes to Court`,
|
||||
text: `${hero.title || "A wanderer"} named ${hero.name} drinks in your hall and praises your name — loudly enough that refusing would be an insult.\n\n(LDR ${hero.st.ldr} · WAR ${hero.st.war} · INT ${hero.st.int})\nTraits: ${hero.traits.map(t => D.TRAITS[t]?.name).join(", ")}`,
|
||||
choices: [
|
||||
{ label: `Recruit ${hero.name}`, hint: "Welcome him to your banner", effect: { fn: "recruit_hero", gen: hero.id } },
|
||||
{ label: "Turn him away", hint: "He will serve someone else", effect: { fn: "hero_leave", gen: hero.id } },
|
||||
],
|
||||
}));
|
||||
} else {
|
||||
// unknown talent
|
||||
G.pendingEvents.push(makeEvent({
|
||||
kind: "opportunity", art: "🌾",
|
||||
title: "An Unknown Talent",
|
||||
text: `In the markets below ${cap.name}, your steward notices a commoner correcting generals' battle maps with a charcoal stick.`,
|
||||
choices: [
|
||||
{ label: "Summon and test them (−200 gold)", hint: "Might be nobody. Might be everything.", effect: { fn: "test_talent", cost: 200 } },
|
||||
{ label: "Not worth a lord's time", effect: { fn: "noop" } },
|
||||
],
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- EFFECT RESOLVER ----------------
|
||||
export function applyEffect(effect) {
|
||||
if (!effect || !effect.fn) return null;
|
||||
const pf = playerFaction();
|
||||
switch (effect.fn) {
|
||||
case "noop": case "noop_good": case "noop_honor": {
|
||||
if (effect.fn === "noop_honor") { pf.honor += 8; log("You decline with grace. Men approve.", "good"); }
|
||||
return { msg: "" };
|
||||
}
|
||||
case "defect_bribe": {
|
||||
if (pf.gold < effect.cost) return { msg: "You lack the gold — he leaves anyway." , fail:true };
|
||||
pf.gold -= effect.cost;
|
||||
const g = GEN(effect.gen);
|
||||
if (g) { g.loyalty = clamp(g.loyalty + effect.boost, 0, 100); }
|
||||
return { msg: `${g?.name} stays, bought dearly.` };
|
||||
}
|
||||
case "defect_promote": {
|
||||
const g = GEN(effect.gen);
|
||||
if (g) { g.loyalty = clamp(g.loyalty + 22, 0, 100); g.promotedTurn = G.turnCount; g.title = g.title || "General of the Household"; }
|
||||
return { msg: `${g?.name} accepts a new title — for now.` };
|
||||
}
|
||||
case "defect_release": {
|
||||
const g = GEN(effect.gen);
|
||||
if (g) {
|
||||
transferGeneral(g, effect.dest);
|
||||
G.stats.betrayals++;
|
||||
chronicle(`${g.name} leaves your banner for ${F(effect.dest)?.name}.`, "war");
|
||||
}
|
||||
return { msg: `${g?.name} rides for ${F(effect.dest)?.name}.` };
|
||||
}
|
||||
case "defect_imprison": {
|
||||
const g = GEN(effect.gen);
|
||||
if (g) {
|
||||
captureGeneral(g, pf.id);
|
||||
pf.honor -= 5; pf.fear += 5;
|
||||
}
|
||||
return { msg: `${g?.name} is taken to the dungeons. The court watches in silence.` };
|
||||
}
|
||||
case "peace_accept": {
|
||||
makePeace(pf.id, effect.fac);
|
||||
pf.gold += effect.tribute;
|
||||
return { msg: `Peace signed. Tribute received.` };
|
||||
}
|
||||
case "peace_demand_city": {
|
||||
const fac = F(effect.fac);
|
||||
const borderCity = fac.cities.map(id => CITY(id)).find(c => provNeighbors(c.prov).some(p => G.provinces[p].cities.some(x => CITY(x).owner === pf.id)));
|
||||
if (borderCity) {
|
||||
assignCity(borderCity.id, pf.id);
|
||||
makePeace(pf.id, effect.fac);
|
||||
chronicle(`${borderCity.name} is ceded to you for peace.`, "epic");
|
||||
return { msg: `${borderCity.name} is yours.` };
|
||||
}
|
||||
makePeace(pf.id, effect.fac);
|
||||
return { msg: "They had no cities to spare — peace signed anyway." };
|
||||
}
|
||||
case "peace_refuse":
|
||||
return { msg: "The envoys leave empty-handed. The war goes on." };
|
||||
case "plague_quarantine": {
|
||||
if (pf.gold >= 300) {
|
||||
pf.gold -= 300;
|
||||
CITY(effect.city).unrest = Math.max(0, CITY(effect.city).unrest - 10);
|
||||
return { msg: "Guards seal the sick streets. It may be enough." };
|
||||
}
|
||||
return { msg: "No gold for quarantine." };
|
||||
}
|
||||
case "caravan_horses": {
|
||||
if (pf.gold < effect.cost) return { msg: "Not enough gold.", fail: true };
|
||||
pf.gold -= effect.cost;
|
||||
const cap = CITY(pf.capital);
|
||||
cap.garrison.cav = (cap.garrison.cav || 0) + 300;
|
||||
return { msg: "300 western horses join your capital garrison." };
|
||||
}
|
||||
case "caravan_grain": {
|
||||
if (pf.food < 800) return { msg: "Not enough food to sell.", fail: true };
|
||||
pf.food -= 800; pf.gold += 600;
|
||||
return { msg: "Grain sold at a handsome price." };
|
||||
}
|
||||
case "refugees_accept": {
|
||||
if (pf.food < 700) return { msg: "Your granaries cannot feed them.", fail: true };
|
||||
pf.food -= 700;
|
||||
const c = CITY(effect.city);
|
||||
c.pop += 40; c.order = clamp(c.order + 8, 5, 100); c.levies += 60;
|
||||
pf.honor += 5;
|
||||
return { msg: "They kneel at your gate, weeping. Your fame grows." };
|
||||
}
|
||||
case "ambition_grant": {
|
||||
if (pf.gold < effect.cost) return { msg: "Not enough gold.", fail: true };
|
||||
pf.gold -= effect.cost;
|
||||
const g = GEN(effect.gen);
|
||||
g.loyalty = clamp(g.loyalty + 20, 0, 100); g.promotedTurn = G.turnCount;
|
||||
return { msg: `${g.name}, satisfied — for now.` };
|
||||
}
|
||||
case "ambition_insult": {
|
||||
const g = GEN(effect.gen);
|
||||
g.loyalty = clamp(g.loyalty - 15, 0, 100);
|
||||
g.insulted = true;
|
||||
return { msg: `${g.name} bows — too smoothly. Something cold enters his eyes.` };
|
||||
}
|
||||
case "ambition_spy": {
|
||||
const g = GEN(effect.gen);
|
||||
if (chance(0.5 + (g.traits.includes("spymaster") ? -0.3 : 0))) {
|
||||
if (g.loyalty < 45) return { msg: `Your spies confirm it: ${g.name} exchanges letters with your enemies! (You may imprison him from his panel.)` };
|
||||
return { msg: `Spies find nothing but ledgers and poetry. Perhaps he is honest.` };
|
||||
}
|
||||
g.loyalty = clamp(g.loyalty - 5, 0, 100);
|
||||
return { msg: `Your spy was discovered snooping. ${g.name} is offended.` };
|
||||
}
|
||||
case "ambition_ignore": {
|
||||
const g = GEN(effect.gen);
|
||||
g.loyalty = clamp(g.loyalty - 12, 0, 100);
|
||||
g.morale = clamp(g.morale - 5, 0, 100);
|
||||
return { msg: `${g.name} says nothing. That is worse.` };
|
||||
}
|
||||
case "marry": {
|
||||
const unmarried = factionGenerals(pf.id).find(g => !g.isLeader && g.age >= 17 && g.age <= 45 && !g.married);
|
||||
if (unmarried) unmarried.married = true;
|
||||
trustBump(pf.id, effect.fac, 30);
|
||||
if (!G.alliances.includes(warKey(pf.id, effect.fac))) G.alliances.push(warKey(pf.id, effect.fac));
|
||||
pf.legitimacy = clamp(pf.legitimacy + 5, 0, 100);
|
||||
chronicle(`A wedding joins your house to ${F(effect.fac).name}.`, "good");
|
||||
return { msg: "Red lanterns hang from every gate. Two houses become kin." };
|
||||
}
|
||||
case "marry_decline": {
|
||||
trustBump(pf.id, effect.fac, -8);
|
||||
return { msg: "The envoy departs with stiff courtesy." };
|
||||
}
|
||||
case "recruit_hero": {
|
||||
const g = GEN(effect.gen);
|
||||
if (g) {
|
||||
transferGeneral(g, pf.id);
|
||||
g.location = pf.capital;
|
||||
G.stats.recruited++;
|
||||
chronicle(`${g.name} joins your cause.`, "good");
|
||||
return { msg: `${g.name} kneels: "My sword is yours, my lord."` };
|
||||
}
|
||||
return { msg: "He vanished along the road." };
|
||||
}
|
||||
case "hero_leave": {
|
||||
const g = GEN(effect.gen);
|
||||
if (g) g.location = null;
|
||||
return { msg: "He salutes and vanishes into the crowd. Someone else will find him." };
|
||||
}
|
||||
case "test_talent": {
|
||||
if (pf.gold < effect.cost) return { msg: "Not enough gold.", fail: true };
|
||||
pf.gold -= effect.cost;
|
||||
if (chance(0.55)) {
|
||||
const fresh = spawnGenericTalent();
|
||||
fresh.location = pf.capital;
|
||||
transferGeneral(fresh, pf.id);
|
||||
G.stats.recruited++; G.stats.discoveries++;
|
||||
chronicle(`From nowhere, ${fresh.name} joins you — remembered by history or not, that is up to fate.`, "good");
|
||||
return { msg: `${fresh.name} (LDR ${fresh.st.ldr} WAR ${fresh.st.war} INT ${fresh.st.int}) proves remarkable! He enters your service.` };
|
||||
}
|
||||
return { msg: "The 'genius' turns out to be a grain merchant with opinions." };
|
||||
}
|
||||
case "protect_emperor": protectEmperor(); return { msg: "You take the Emperor under your protection." };
|
||||
case "emperor_quiet": pf.legitimacy = clamp(pf.legitimacy + effect.legit, 0, 100); return { msg: "The Emperor rests quietly in your care." };
|
||||
case "inherit_cities": {
|
||||
const from = effect.from;
|
||||
for (const cid of [...F(from).cities]) assignCity(cid, pf.id);
|
||||
pf.legitimacy = clamp(pf.legitimacy + effect.legit, 0, 100);
|
||||
pf.fame += 8;
|
||||
for (const g of factionGenerals(from)) transferGeneral(g, pf.id);
|
||||
collapseFaction(from, "by inheritance");
|
||||
return { msg: "Xuzhou accepts your banner." };
|
||||
}
|
||||
default:
|
||||
console.warn("unknown effect", effect.fn);
|
||||
return { msg: "" };
|
||||
}
|
||||
}
|
||||
|
||||
function spawnGenericTalent() {
|
||||
return stateModule.makeGenericGeneral("neutral", 58);
|
||||
}
|
||||
import * as stateModule from "./state.js";
|
||||
+818
@@ -0,0 +1,818 @@
|
||||
// ============================================================
|
||||
// MAIN — bootstrapping, game loop, orchestration
|
||||
// ============================================================
|
||||
import * as D from "./data.js";
|
||||
import { G, F, GEN, CITY, newGame, loadGame, listSaves, saveGame, hasAutosave, playerFaction } from "./state.js";
|
||||
import * as sim from "./sim.js";
|
||||
import { applyEffect } from "./events.js";
|
||||
import { adjacentProvinces } from "./world.js";
|
||||
import { chronicle } from "./state.js";
|
||||
import { GameMap } from "./map3d.js";
|
||||
import { BattleScene } from "./battle3d.js";
|
||||
import { UI } from "./ui.js";
|
||||
import { audio } from "./audio.js";
|
||||
|
||||
const $ = sel => document.querySelector(sel);
|
||||
const $$ = sel => [...document.querySelectorAll(sel)];
|
||||
const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; };
|
||||
const fmt = n => n >= 10000 ? (n / 1000).toFixed(1) + "k" : Math.round(n).toLocaleString();
|
||||
const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
|
||||
let map = null;
|
||||
let battleScene = null;
|
||||
let ui = null;
|
||||
let setupState = null;
|
||||
|
||||
// ================= SCREENS =================
|
||||
function show(id) {
|
||||
$$(".screen").forEach(s => s.classList.add("hidden"));
|
||||
$(id)?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function initTitle() {
|
||||
$("#btn-continue").disabled = !hasAutosave();
|
||||
$("#btn-new-campaign").onclick = () => { audio.init(); audio.click(); setupState = { mode: "campaign" }; show("#faction-screen"); renderFactionList(); };
|
||||
$("#btn-new-challenge").onclick = () => { audio.init(); audio.click(); setupState = { mode: "challenge" }; show("#faction-screen"); renderFactionList(true); };
|
||||
$("#btn-continue").onclick = () => { audio.init(); startFromLoad("autosave"); };
|
||||
$("#btn-load-title").onclick = () => { audio.init(); openLoadDialog(); };
|
||||
$("#btn-help-title").onclick = () => {
|
||||
alert("WARLORD'S FATE\n\nRaise a banner in the dying Han empire. Develop cities, recruit legendary officers, march armies across a stylized 3D China, and write your own chronicle.\n\nEvery campaign creates different history.");
|
||||
};
|
||||
$("#btn-back-title").onclick = () => show("#title-screen");
|
||||
$("#btn-back-title2").onclick = () => show("#title-screen");
|
||||
$("#btn-load-close").onclick = () => $("#load-modal").classList.add("hidden");
|
||||
$("#btn-end-title").onclick = () => location.reload();
|
||||
}
|
||||
|
||||
function renderFactionList(challengeMode = false) {
|
||||
const grid = $("#faction-list");
|
||||
grid.innerHTML = "";
|
||||
// custom warlord card
|
||||
const customCard = el("div", "faction-card");
|
||||
customCard.innerHTML = `
|
||||
<div class="fc-banner" style="background:#c2452d"></div>
|
||||
<h4>⚑ Create Custom Warlord</h4>
|
||||
<div class="fc-leader">Your own house</div>
|
||||
<p>Choose an origin, banner, name and starting city. Begin as a nobody with one city.</p>`;
|
||||
customCard.onclick = () => {
|
||||
audio.click();
|
||||
setupState.mode ??= "campaign";
|
||||
setupState.playerType = "custom";
|
||||
show("#setup-screen");
|
||||
renderSetup();
|
||||
};
|
||||
grid.appendChild(customCard);
|
||||
|
||||
for (const fdef of D.FACTION_DEFS) {
|
||||
if (fdef.id === "neutral") continue;
|
||||
const card = el("div", "faction-card");
|
||||
const cities = fdef.startCities.map(cid => D.CITY_DEFS.find(c => c[0] === cid)[1]).join(", ");
|
||||
card.innerHTML = `
|
||||
<div class="fc-banner" style="background:${fdef.color}"></div>
|
||||
<h4>${fdef.name}</h4>
|
||||
<div class="fc-leader">${fdef.title}</div>
|
||||
<p>${fdef.desc}</p>
|
||||
<div class="fc-cities">Starts with: ${cities}${challengeMode ? "" : ""}</div>`;
|
||||
card.dataset.fac = fdef.id;
|
||||
card.onclick = () => {
|
||||
audio.click();
|
||||
$$(".faction-card").forEach(c => c.classList.remove("sel"));
|
||||
card.classList.add("sel");
|
||||
setupState.playerType = "faction";
|
||||
setupState.factionId = fdef.id;
|
||||
};
|
||||
grid.appendChild(card);
|
||||
}
|
||||
$("#btn-faction-go").onclick = () => {
|
||||
if (!setupState.factionId && setupState.playerType !== "custom") { toastMsg("Select a warlord first."); return; }
|
||||
startNewGame({ ...setupState });
|
||||
};
|
||||
}
|
||||
|
||||
function renderSetup() {
|
||||
const st = setupState;
|
||||
st.custom ??= { rulerName: "Minh", color: "#c2452d", emblem: "⚔", origin: "governor", cityId: null, factionName: "" };
|
||||
const c = st.custom;
|
||||
// origins
|
||||
const originList = $("#origin-list");
|
||||
originList.innerHTML = "";
|
||||
for (const [key, o] of Object.entries(D.ORIGINS)) {
|
||||
const ch = el("div", `choice ${c.origin === key ? "sel" : ""}`, `${o.icon} ${o.name}`);
|
||||
ch.onclick = () => { c.origin = key; audio.click(); renderSetup(); };
|
||||
originList.appendChild(ch);
|
||||
}
|
||||
$("#origin-desc").innerHTML = `<b>${D.ORIGINS[c.origin].name}</b> — ${D.ORIGINS[c.origin].desc}`;
|
||||
$("#inp-ruler").value = c.rulerName;
|
||||
$("#inp-ruler").oninput = e => c.rulerName = e.target.value || "Nameless";
|
||||
$("#inp-color").value = c.color;
|
||||
$("#inp-color").oninput = e => c.color = e.target.value;
|
||||
$("#sel-emblem").value = c.emblem;
|
||||
$("#sel-emblem").onchange = e => c.emblem = e.target.value;
|
||||
$("#inp-factionname").oninput = e => c.factionName = e.target.value;
|
||||
// spawn cities
|
||||
const list = $("#city-list");
|
||||
list.innerHTML = "";
|
||||
for (const cid of D.PLAYER_SPAWN_CITIES) {
|
||||
const cd = D.CITY_DEFS.find(x => x[0] === cid);
|
||||
const prov = D.PROVINCES[cd[3]];
|
||||
const ch = el("div", `choice ${c.cityId === cid ? "sel" : ""}`, `${cd[1]} · ${prov.name}`);
|
||||
ch.onclick = () => { c.cityId = cid; audio.click(); renderSetup(); };
|
||||
list.appendChild(ch);
|
||||
}
|
||||
if (!c.cityId) c.cityId = D.PLAYER_SPAWN_CITIES[0];
|
||||
$("#city-desc").textContent = {
|
||||
xuchang: "Rich plains at the crossroads of the Central Plains — everyone's future prize.",
|
||||
wan: "Walled city of Nanyang, guarding Jingzhou's northern gates.",
|
||||
lujiang: "River town between the Huai and the Yangtze. Boats and bandits.",
|
||||
changsha: "Prosperous southern granary, far from the northern wars.",
|
||||
yunnan: "Remote frontier among tribes and jade mountains.",
|
||||
anding: "Hard frontier town of the northwest, horse country.",
|
||||
}[c.cityId];
|
||||
// difficulty
|
||||
const dl = $("#diff-list");
|
||||
dl.innerHTML = "";
|
||||
st.difficulty ??= "normal";
|
||||
for (const [key, d] of Object.entries(D.DIFFICULTIES)) {
|
||||
const ch = el("div", `choice small ${st.difficulty === key ? "sel" : ""}`, `${d.name}`);
|
||||
ch.title = d.desc;
|
||||
ch.onclick = () => { st.difficulty = key; audio.click(); renderSetup(); };
|
||||
dl.appendChild(ch);
|
||||
}
|
||||
$("#btn-begin").onclick = () => startNewGame({ ...setupState });
|
||||
}
|
||||
|
||||
// ================= START =================
|
||||
function startNewGame(opts) {
|
||||
audio.init(); audio.click(); audio.gong(0.1);
|
||||
newGame({
|
||||
mode: opts.mode === "challenge" ? "challenge" : "campaign",
|
||||
difficulty: opts.difficulty || "normal",
|
||||
playerType: opts.playerType,
|
||||
factionId: opts.factionId,
|
||||
custom: opts.playerType === "custom" ? { ...opts.custom, rulerName: ($("#inp-ruler").value || opts.custom.rulerName) } : undefined,
|
||||
});
|
||||
enterGame();
|
||||
}
|
||||
|
||||
function openLoadDialog(inGame = false) {
|
||||
const modal = $("#load-modal");
|
||||
const slotsBox = $("#save-slots");
|
||||
slotsBox.innerHTML = "";
|
||||
const saves = listSaves();
|
||||
if (!saves.length) slotsBox.innerHTML = '<p style="color:#97835b;text-align:center;padding:14px">No saved campaigns found.</p>';
|
||||
for (const sv of saves) {
|
||||
const row = el("div", "journal-entry epic", `<b>${esc(sv.label)}</b><br><span style="color:#97835b;font-size:11px">${new Date(sv.when).toLocaleString()}</span>`);
|
||||
row.style.cursor = "pointer";
|
||||
row.onclick = () => {
|
||||
modal.classList.add("hidden");
|
||||
const g = loadGame(sv.slot);
|
||||
if (!g) { toastMsg("Failed to load."); return; }
|
||||
selectedArmyId = null;
|
||||
closeModal();
|
||||
if ($("#end-screen") && !$("#end-screen").classList.contains("hidden")) {
|
||||
$("#game-screen").classList.remove("hidden");
|
||||
$("#end-screen").classList.add("hidden");
|
||||
audio.playMusic("map");
|
||||
}
|
||||
enterGame();
|
||||
};
|
||||
slotsBox.appendChild(row);
|
||||
}
|
||||
modal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function startFromLoad(slot) {
|
||||
const g = loadGame(slot);
|
||||
if (!g) { toastMsg("No save found."); return; }
|
||||
enterGame();
|
||||
}
|
||||
|
||||
function enterGame() {
|
||||
show("#game-screen");
|
||||
if (!map) {
|
||||
map = new GameMap($("#gl"), {
|
||||
onClick: hit => handleMapClick(hit),
|
||||
onHover: hit => handleHover(hit),
|
||||
});
|
||||
}
|
||||
if (!ui) {
|
||||
ui = new UI(makeHooks());
|
||||
}
|
||||
// top-bar buttons
|
||||
$("#btn-save").onclick = () => {
|
||||
const ok = saveGame(`slot-${Date.now().toString(36)}`);
|
||||
toastMsg(ok ? "Campaign saved." : "Save failed.");
|
||||
saveGame("autosave");
|
||||
};
|
||||
$("#btn-menu").onclick = () => {
|
||||
openChoiceModal("☰ War Council", "", [
|
||||
{ label: "💾 Save campaign", hint: "Writes a save slot", cb: () => { const ok = saveGame(`slot-${Date.now().toString(36)}`); toastMsg(ok ? "Saved." : "Save failed."); } },
|
||||
{ label: "▤ Load a save", hint: "Opens the save list", cb: () => openLoadDialog(true) },
|
||||
{ label: `${audio.enabled ? "🔇 Mute" : "🔊 Unmute"} audio`, hint: "Toggle all sound", cb: () => { audio.toggleEnabled(); } },
|
||||
{ label: "🏳 Abandon to title", hint: "End this session", cb: () => location.reload() },
|
||||
{ label: "✕ Close", hint: "", cb: null },
|
||||
]);
|
||||
};
|
||||
$("#btn-audio").onclick = () => { audio.init(); const on = audio.toggleEnabled(); toastMsg(on ? "Sound on." : "Sound muted."); };
|
||||
|
||||
syncMapToState(true);
|
||||
ui.refreshAll();
|
||||
audio.playMusic("map");
|
||||
ui.turnBanner(`${G.mode === "challenge" ? "THE HUNDRED DAYS TRIAL" : "THE CHRONICLE BEGINS"}<br><span style="font-size:26px;color:#cab98f">Winter ${G.year}</span>`);
|
||||
processEventQueue();
|
||||
}
|
||||
|
||||
// ================= MAP SYNC =================
|
||||
function factionColors() {
|
||||
const out = {};
|
||||
for (const f of Object.values(G.factions)) out[f.id] = f.color;
|
||||
return out;
|
||||
}
|
||||
|
||||
function provOwnerMap() {
|
||||
const m = {};
|
||||
for (const p of Object.keys(D.PROVINCES)) {
|
||||
const owners = G.provinces[p].cities.map(id => CITY(id).owner).filter(Boolean);
|
||||
m[p] = owners.length ? owners.sort((a, b) => owners.filter(x => x === b).length - owners.filter(x => x === a).length)[0] : null;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function syncMapToState(full = false) {
|
||||
const colors = factionColors();
|
||||
map.setProvinceOwners(provOwnerMap());
|
||||
map.refreshOwnership(colors);
|
||||
const citiesOut = {};
|
||||
for (const c of Object.values(G.cities)) {
|
||||
citiesOut[c.id] = { ...c, devTier: sim.cityDevTier(c) };
|
||||
}
|
||||
map.syncCities(citiesOut, colors);
|
||||
positionArmies();
|
||||
updateLabelsPool();
|
||||
}
|
||||
|
||||
function worldProvCentroid(letter) {
|
||||
// average anchor of owned-or-any cities, fallback raw centroid
|
||||
const cs = G.provinces[letter].cities.map(id => CITY(id));
|
||||
if (!cs.length) return [60, 55];
|
||||
let x = 0, z = 0;
|
||||
for (const c of cs) { x += c.x; z += c.z; }
|
||||
return [x / cs.length, z / cs.length];
|
||||
}
|
||||
|
||||
function cityAnchor(provLetter) {
|
||||
const cs = G.provinces[provLetter].cities.map(id => CITY(id));
|
||||
if (!cs.length) return null;
|
||||
const c = cs[Math.floor(cs.length / 2)];
|
||||
return [c.x, c.z];
|
||||
}
|
||||
|
||||
function positionArmies() {
|
||||
const colors = factionColors();
|
||||
map.syncArmies(G.armies, colors,
|
||||
l => worldProvCentroid(l),
|
||||
l => cityAnchor(l));
|
||||
}
|
||||
|
||||
// ---------------- labels ----------------
|
||||
let labelNodes = new Map();
|
||||
|
||||
function updateLabelsPool() {
|
||||
const wrap = $("#labels");
|
||||
// cities
|
||||
const items = [];
|
||||
for (const c of Object.values(G.cities)) items.push({ kind: "city", id: c.id, x: c.x, y: 8.5, z: c.z });
|
||||
for (const a of Object.values(G.armies)) {
|
||||
const node = map.armyNodes.get(a.id);
|
||||
if (node) items.push({ kind: "army", id: a.id, x: node.position.x, y: node.position.y + 5.2, z: node.position.z });
|
||||
}
|
||||
map._labelItems = items;
|
||||
}
|
||||
|
||||
function renderLabels() {
|
||||
if (!map || !map._labelItems) return;
|
||||
const positions = map.computeLabels(map._labelItems);
|
||||
const wrap = $("#labels");
|
||||
const seen = new Set();
|
||||
positions.forEach((pos, i) => {
|
||||
const it = map._labelItems[i];
|
||||
seen.add(it.kind + it.id);
|
||||
let node = labelNodes.get(it.kind + it.id);
|
||||
if (!node) {
|
||||
if (it.kind === "city") {
|
||||
node = el("div", "map-label");
|
||||
node.onclick = () => handleMapClick({ type: "city", id: it.id });
|
||||
} else {
|
||||
node = el("div", "army-label");
|
||||
node.onclick = () => handleMapClick({ type: "army", id: it.id });
|
||||
}
|
||||
labelNodes.set(it.kind + it.id, node);
|
||||
wrap.appendChild(node);
|
||||
}
|
||||
node.style.display = pos.visible ? "" : "none";
|
||||
if (pos.visible) {
|
||||
node.style.left = pos.x + "px";
|
||||
node.style.top = pos.y + "px";
|
||||
if (it.kind === "city") {
|
||||
const c = CITY(it.id);
|
||||
const f = F(c.owner);
|
||||
const tier = sim.cityDevTier(c);
|
||||
node.innerHTML = `<div class="ml-name">${esc(c.name)}</div><div class="ml-sub"><span style="color:${f?.color ?? '#999'}">■</span> ${["","village","town","city","grand city","capital"][tier] ?? ""}${c.siegedBy ? " ⚔siege" : ""}</div>`;
|
||||
node.style.opacity = pos.dist > 150 ? 0.35 : pos.dist > 90 ? 0.75 : 1;
|
||||
} else {
|
||||
const a = G.armies[it.id];
|
||||
if (!a) { node.style.display = "none"; return; }
|
||||
const total = Object.values(a.troops).reduce((s, v) => s + v, 0);
|
||||
node.style.borderColor = F(a.faction).color;
|
||||
node.style.color = "#f0e3bd";
|
||||
node.innerHTML = `${GEN(a.genId)?.name?.split(" ")[0] ?? "?"} · ${fmt(total)}${a.faction === G.playerFaction ? " ⚑" : ""}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const [k, node] of labelNodes) {
|
||||
if (!seen.has(k)) { node.remove(); labelNodes.delete(k); }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- INTERACTION ----------------
|
||||
let selectedArmyId = null;
|
||||
|
||||
function handleMapClick(hit) {
|
||||
audio.init();
|
||||
if (!hit) { selectedArmyId = null; map.setSelected(null); return; }
|
||||
|
||||
if (hit.type === "city") {
|
||||
selectedArmyId = null;
|
||||
const c = CITY(hit.id);
|
||||
const topY = map.hexTop?.get(c.col + "," + c.row) ?? 2;
|
||||
map.setSelected({ type: "city", id: c.id, x: c.x, z: c.z });
|
||||
ui.showCityPanel(c.id);
|
||||
map.focusOn(c.x, c.z, Math.min(map.camGoal.dist, 62));
|
||||
return;
|
||||
}
|
||||
if (hit.type === "army") {
|
||||
const a = G.armies[hit.id];
|
||||
if (!a) return;
|
||||
const node = map.armyNodes.get(a.id);
|
||||
map.setSelected({ type: "army", id: a.id, x: node.position.x, z: node.position.z });
|
||||
if (a.faction === G.playerFaction) {
|
||||
selectedArmyId = a.id;
|
||||
ui.toast(`Army of ${GEN(a.genId)?.name} selected — click an adjacent province to march.`);
|
||||
ui.openTab("armies");
|
||||
} else {
|
||||
ui.openTab("armies"); // foreign summary shown there
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (hit.type === "prov") {
|
||||
const letter = hit.id;
|
||||
// army movement?
|
||||
if (selectedArmyId) {
|
||||
const army = G.armies[selectedArmyId];
|
||||
if (army && army.faction === G.playerFaction) {
|
||||
tryMovePlayerArmy(army, letter);
|
||||
return;
|
||||
}
|
||||
}
|
||||
map.setSelected({ type: "prov", id: letter, x: hit.x, z: hit.z });
|
||||
showProvinceInfo(letter);
|
||||
}
|
||||
}
|
||||
|
||||
function handleHover(hit, cx, cy) {
|
||||
if (!hit) { $("#hover-card").classList.add("hidden"); return; }
|
||||
const cardEl = $("#hover-card");
|
||||
let html = "";
|
||||
if (hit.type === "city") {
|
||||
const c = CITY(hit.id);
|
||||
const f = F(c.owner);
|
||||
const det = sim.cityDetail(c.id);
|
||||
html = `<h4>${esc(c.name)} <span class="hc-sub">${esc(f?.name ?? "")}</span></h4>
|
||||
Garrison ${fmt(det.garrison)} · Walls ${c.buildings.wall}<br>Tier ${det.tier} · Pop ${fmt(c.pop)}k`;
|
||||
} else if (hit.type === "army") {
|
||||
const a = G.armies[hit.id];
|
||||
if (a) html = `<h4>${esc(GEN(a.genId)?.name ?? "?")}<span class="hc-sub">${esc(F(a.faction)?.name)}</span></h4>${fmt(Object.values(a.troops).reduce((s, v) => s + v, 0))} men · morale ${Math.round(a.morale)}`;
|
||||
} else if (hit.type === "prov") {
|
||||
const P = D.PROVINCES[hit.id];
|
||||
const owner = provOwnerMap()[hit.id];
|
||||
html = `<h4>${P.name} <span class="cn-small">${P.cn}</span></h4>${esc(P.desc)}<br><span style="color:${F(owner)?.color ?? "#999"}">■</span> ${F(owner)?.name ?? "unclaimed"}`;
|
||||
}
|
||||
if (!html) { cardEl.classList.add("hidden"); return; }
|
||||
cardEl.innerHTML = html;
|
||||
cardEl.classList.remove("hidden");
|
||||
if (cx != null) {
|
||||
const x = Math.min(window.innerWidth - 290, cx + 14);
|
||||
const y = Math.min(window.innerHeight - 140, cy + 10);
|
||||
cardEl.style.left = x + "px"; cardEl.style.top = y + "px";
|
||||
}
|
||||
}
|
||||
|
||||
function showProvinceInfo(letter) {
|
||||
const P = D.PROVINCES[letter];
|
||||
const box = el("div");
|
||||
box.appendChild(el("div", "panel-h", `${P.name} <span class="cn-small">${P.cn}</span>`));
|
||||
const owner = provOwnerMap()[letter];
|
||||
box.insertAdjacentHTML("beforeend", `<p style="color:#cdbb93;line-height:1.7;font-size:13px">${esc(P.desc)}</p>`);
|
||||
box.insertAdjacentHTML("beforeend", `<div style="color:#d8b25f;font-size:13px;margin-bottom:8px">Under ${esc(F(owner)?.name ?? "no banner")}</div>`);
|
||||
for (const cid of G.provinces[letter].cities) {
|
||||
const c = CITY(cid);
|
||||
const det = sim.cityDetail(cid);
|
||||
box.insertAdjacentHTML("beforeend", `<div class="journal-entry"><b>${esc(c.name)}</b> — garrison ${fmt(det.garrison)}, walls ${c.buildings.wall}, order ${Math.round(c.order)}</div>`);
|
||||
}
|
||||
ui.swapPanel(box);
|
||||
}
|
||||
|
||||
// ---------------- ARMY MOVEMENT & BATTLES ----------------
|
||||
function tryMovePlayerArmy(army, targetProv) {
|
||||
if (army.moved) { toastMsg("This army already marched this month."); return; }
|
||||
if (!adjacentProvinces(army.prov, targetProv)) { toastMsg("Provinces must be adjacent."); return; }
|
||||
|
||||
// what's there?
|
||||
const hostileArmies = Object.values(G.armies).filter(a => a.prov === targetProv && sim.areHostile(army.faction, a.faction));
|
||||
const enemyCity = G.provinces[targetProv].cities.map(id => CITY(id))
|
||||
.find(c => c.owner && c.owner !== army.faction && (sim.areHostile(army.faction, c.owner) || c.owner === "neutral"));
|
||||
const neutralCity = !enemyCity ? null : enemyCity.owner === "neutral" ? enemyCity : null;
|
||||
|
||||
const doPlainMove = () => {
|
||||
const res = sim.moveArmy(army, targetProv, { attackNeutral: false });
|
||||
finishMove(res, army, targetProv);
|
||||
};
|
||||
|
||||
if (hostileArmies.length || (enemyCity && !neutralCity)) {
|
||||
openBattleDialog(army, targetProv, hostileArmies[0], enemyCity && !neutralCity ? enemyCity : null);
|
||||
} else if (enemyCity && neutralCity) {
|
||||
// neutral: confirm
|
||||
openChoiceModal("⚔ The Local Garrison", `The militia of ${neutralCity.name} will resist your entry. Attacking them costs no declaration of war — but blood is blood.`, [
|
||||
{ label: "Attack the garrison", hint: "Begin assault", cb: () => openSiegeDialog(army, neutralCity, {}) },
|
||||
{ label: "Stand down", hint: "Cancel", cb: null },
|
||||
]);
|
||||
} else {
|
||||
doPlainMove();
|
||||
}
|
||||
}
|
||||
|
||||
function finishMove(res, army, targetProv) {
|
||||
if (!res.ok) { toastMsg(res.why ?? "Cannot move."); return; }
|
||||
selectedArmyId = null;
|
||||
syncMapToState();
|
||||
ui.refreshAll();
|
||||
if (res.battle && res.result) {
|
||||
playReplayRecords([res.result]);
|
||||
} else {
|
||||
audio.horn(0);
|
||||
toastMsg(`Army marches into ${D.PROVINCES[targetProv].name}.`);
|
||||
}
|
||||
}
|
||||
|
||||
// pre-battle dialog
|
||||
function openBattleDialog(army, targetProv, defArmy, city) {
|
||||
const gen = GEN(army.genId);
|
||||
const isSiege = !!city;
|
||||
const title = isSiege ? `⚔ Assault on ${city.name}` :
|
||||
`⚔ Battle of ${D.PROVINCES[targetProv].name}`;
|
||||
const body = el("div");
|
||||
body.insertAdjacentHTML("beforeend", `
|
||||
<p style="color:#d9c89f;line-height:1.7;font-size:13.5px">
|
||||
${isSiege
|
||||
? `${gen.name} stands before the walls of ${esc(city.name)}. Garrison: ~${fmt(Object.values(city.garrison).reduce((s, v) => s + v, 0))} men behind walls level ${city.buildings.wall}.`
|
||||
: `${gen.name} meets ${esc(GEN(defArmy.genId)?.name ?? "the enemy")} in the field. Enemy strength: ~${fmt(Object.values(defArmy.troops).reduce((s, v) => s + v, 0))} men.`}
|
||||
</p>`);
|
||||
// formation picker
|
||||
body.insertAdjacentHTML("beforeend", `<div class="sub-h">Formation</div>`);
|
||||
const fRow = el("div", "choice-row");
|
||||
let formation = "balanced";
|
||||
for (const [key, f] of Object.entries(D.FORMATIONS)) {
|
||||
const ch = el("div", `choice ${key === "balanced" ? "sel" : ""}`, `${f.icon} ${f.name}`);
|
||||
ch.title = f.desc;
|
||||
ch.onclick = () => { formation = key; fRow.querySelectorAll(".choice").forEach(x => x.classList.remove("sel")); ch.classList.add("sel"); audio.click(); };
|
||||
fRow.appendChild(ch);
|
||||
}
|
||||
body.appendChild(fRow);
|
||||
body.insertAdjacentHTML("beforeend", `<div id="form-desc" class="hint-box">${D.FORMATIONS.balanced.desc}</div>`);
|
||||
fRow.addEventListener("click", () => { body.querySelector("#form-desc").textContent = D.FORMATIONS[formation].desc; });
|
||||
|
||||
const choices = [];
|
||||
if (isSiege) {
|
||||
choices.push(
|
||||
{ label: "🔥 Storm the walls", hint: "Bloody assault; walls favor defenders", cb: () => commitAttack({ formation, stance: "assault" }) },
|
||||
{ label: "⏳ Lay siege and starve them", hint: "Wait months; their numbers dwindle", cb: () => commitBesiege() },
|
||||
{ label: "💰 Bribe the gatekeeper (800g)", hint: "May open the gates… or betray you", cb: () => commitBribery() },
|
||||
{ label: "🌙 Secret infiltration", hint: "INT test; open gates at night", cb: () => commitInfiltrate() },
|
||||
);
|
||||
} else {
|
||||
choices.push(
|
||||
{ label: "⚔ Give battle!", hint: "Cinematic tactical engagement", cb: () => commitAttack({ formation, stance: "assault" }) },
|
||||
{ label: "🛡 Fight defensively", hint: "Fewer losses, less glory", cb: () => commitAttack({ formation, stance: "defensive" }) },
|
||||
{ label: "Withdraw", hint: "Do not move yet", cb: null },
|
||||
);
|
||||
}
|
||||
function commitAttack(opts2) {
|
||||
closeModal();
|
||||
let rec;
|
||||
if (isSiege) {
|
||||
rec = sim.assaultCity(army, city, opts2);
|
||||
} else {
|
||||
rec = sim.runFieldBattle(army, defArmy, opts2);
|
||||
}
|
||||
army.moved = true;
|
||||
syncMapToState(); ui.refreshAll();
|
||||
playReplayRecords([rec]);
|
||||
}
|
||||
function commitBesiege() {
|
||||
closeModal();
|
||||
city.siegedBy = army.id; city.siegeProgress = 0;
|
||||
army.prov = targetProv; army.moved = true;
|
||||
toastMsg(`Siege lines drawn around ${city.name}. Starve them out.`);
|
||||
syncMapToState(); ui.refreshAll();
|
||||
}
|
||||
function commitBribery() {
|
||||
closeModal();
|
||||
const pf = playerFaction();
|
||||
if (pf.gold < 800) { toastMsg("Not enough gold."); return; }
|
||||
pf.gold -= 800;
|
||||
const cmdr = sim.garrisonCommander(city);
|
||||
const greedyBonus = cmdr?.traits.includes("greedy") ? 0.25 : 0;
|
||||
const loyalPenalty = cmdr?.traits.includes("loyalheart") ? -0.35 : 0;
|
||||
if (Math.random() < 0.45 + greedyBonus + loyalPenalty + (cmdr ? (60 - cmdr.loyalty) / 120 : 0.2)) {
|
||||
if (cmdr) sim.captureGeneral(cmdr, army.faction);
|
||||
sim.captureCity(city, army.faction);
|
||||
army.prov = targetProv; army.moved = true;
|
||||
chronicleToast(`The gates open in the night! ${city.name} falls without a fight.`, "epic");
|
||||
} else {
|
||||
toastMsg(`The gatekeeper took your gold and reported you!`);
|
||||
if (cmdr) cmdr.loyalty = Math.min(100, cmdr.loyalty + 10);
|
||||
}
|
||||
syncMapToState(); ui.refreshAll();
|
||||
}
|
||||
function commitInfiltrate() {
|
||||
closeModal();
|
||||
const genObj = GEN(army.genId);
|
||||
const chance = 0.25 + genObj.st.int / 250 + (genObj.traits.includes("genius") ? 0.15 : 0);
|
||||
if (Math.random() < chance) {
|
||||
toastMsg(`Your infiltrators open the water gate! The walls count for nothing tonight.`);
|
||||
const rec = sim.assaultCity(army, city, { ...{}, formation: "balanced", stance: "assault", infiltrated: true });
|
||||
army.moved = true;
|
||||
syncMapToState(); ui.refreshAll();
|
||||
playReplayRecords([rec]);
|
||||
} else {
|
||||
toastMsg(`The infiltration fails — heads roll on the ramparts.`);
|
||||
army.moved = true;
|
||||
syncMapToState(); ui.refreshAll();
|
||||
}
|
||||
}
|
||||
|
||||
openChoiceModal(title, "", choices, body);
|
||||
}
|
||||
|
||||
// generic choice modal reusing event-card styling
|
||||
function openChoiceModal(title, text, choices, bodyExtra) {
|
||||
const modal = el("div", "modal");
|
||||
modal.id = "temp-modal";
|
||||
const card = el("div", "event-card");
|
||||
card.appendChild(el("div", "event-kind", "DECISION"));
|
||||
card.appendChild(el("h2", "", title));
|
||||
if (bodyExtra) card.appendChild(bodyExtra);
|
||||
if (text) card.appendChild(el("p", "", text));
|
||||
for (const ch of choices) {
|
||||
const b = el("button", "event-choice", `${ch.label}<span class="ec-hint">${ch.hint ?? ""}</span>`);
|
||||
b.onclick = () => { modal.remove(); ch.cb?.(); };
|
||||
card.appendChild(b);
|
||||
}
|
||||
modal.appendChild(card);
|
||||
$("#game-screen").appendChild(modal);
|
||||
}
|
||||
function closeModal() { $("#temp-modal")?.remove(); }
|
||||
|
||||
function chronicleToast(text, type) {
|
||||
chronicle(text, type);
|
||||
ui.toast(text, type);
|
||||
}
|
||||
function toastMsg(text) { ui.toast(text); }
|
||||
|
||||
// ---------------- BATTLE PLAYBACK ----------------
|
||||
function playReplayRecords(records) {
|
||||
const list = records.filter(r => r && r.rounds !== undefined);
|
||||
if (!list.length) { processEventQueue(); return; }
|
||||
playOne(0);
|
||||
|
||||
function playOne(idx) {
|
||||
if (idx >= list.length) {
|
||||
ui.closeBattleOverlay();
|
||||
audio.playMusic("map");
|
||||
syncMapToState(); ui.refreshAll();
|
||||
if (G.gameOver) { showEndScreen(); return; }
|
||||
processEventQueue();
|
||||
return;
|
||||
}
|
||||
const rec = list[idx];
|
||||
const playerIsAtk = rec.meta.atkFaction === G.playerFaction;
|
||||
const playerIsDef = rec.meta.defFaction === G.playerFaction;
|
||||
rec.playerInvolved = playerIsAtk || playerIsDef;
|
||||
rec.playerWon = playerIsAtk ? rec.winner === "atk" : playerIsDef ? rec.winner === "def" : false;
|
||||
rec.playerLosses = playerIsAtk ? rec.atkLost : playerIsDef ? rec.defLost : 0;
|
||||
rec.enemyLosses = playerIsAtk ? rec.defLost : rec.atkLost;
|
||||
|
||||
if (!rec.playerInvolved) {
|
||||
// AI vs AI: quick summary
|
||||
ui.toast(rec.title + ": " + (rec.meta.atkFaction === rec.winner ? rec.meta.atkName : rec.meta.defName) + " won.", "war");
|
||||
playOne(idx + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
ui.openBattleOverlay();
|
||||
audio.playMusic("battle");
|
||||
if (!battleScene) battleScene = new BattleScene($("#battle-gl"));
|
||||
battleScene.resize();
|
||||
battleScene.setup(rec, {
|
||||
atk: { name: rec.meta.atkName, color: F(rec.meta.atkFaction)?.color ?? "#888" },
|
||||
def: { name: rec.meta.defName, color: F(rec.meta.defFaction)?.color ?? "#888" },
|
||||
});
|
||||
ui.updateBattleHUD(rec, -1, null);
|
||||
|
||||
let lastRoundShown = -1;
|
||||
const hooks = {
|
||||
onRound: (i, round) => {
|
||||
ui.updateBattleHUD(rec, i, round);
|
||||
for (const ev of round.events ?? []) {
|
||||
ui.battleCallout(`<span style="color:${F(ev.side === "atk" ? rec.meta.atkFaction : rec.meta.defFaction)?.color}">${esc(ev.who)}</span> — ${esc(D.SKILLS[ev.skillId]?.name ?? "Skill")}!<br><small style="font-family:var(--serif);font-size:16px;color:#e8dcc0">${esc(ev.text ?? "")}</small>`);
|
||||
audio.drum(0, 0.9);
|
||||
setTimeout(() => ui.battleCallout(""), 2100);
|
||||
}
|
||||
},
|
||||
onDone: () => {
|
||||
audio.playMusic(rec.playerWon ? "victory" : "battle");
|
||||
setTimeout(() => audio.playMusic("map"), rec.playerWon ? 2600 : 400);
|
||||
const extras = [];
|
||||
if (rec.genCaptured) extras.push(`Officer captured: <b>${esc(GEN(rec.genCaptured)?.name ?? "?")}</b>`);
|
||||
if (rec.genKilled) extras.push(`Officer slain: <b>${esc(GEN(rec.genKilled)?.name ?? "?")}</b>`);
|
||||
if (rec.captured) extras.push(`<b>${esc(CITY(rec.captured)?.name)}</b> is yours! (+loot)`);
|
||||
if (rec.kind === "siege" && rec.playerWon && !rec.captured) extras.push("The garrison breaks.");
|
||||
// prisoner decision inline
|
||||
let prisonerChoiceDone = false;
|
||||
ui.showBattleResult(rec, extras.join("<br>"), () => {
|
||||
ui.closeBattleOverlay();
|
||||
playOne(idx + 1);
|
||||
});
|
||||
},
|
||||
};
|
||||
battleScene._hooks = hooks;
|
||||
ui.battleCallout("");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- END TURN ----------------
|
||||
let busy = false;
|
||||
$("#btn-endturn").onclick = () => {
|
||||
if (busy || G.gameOver) return;
|
||||
audio.init(); audio.click(); audio.gong(0.05, 0.25);
|
||||
busy = true;
|
||||
$("#btn-endturn").classList.add("busy");
|
||||
selectedArmyId = null;
|
||||
map.setSelected(null);
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const monthNames = ["January","February","March","April","May","June","July","August","September","October","November","December"];
|
||||
ui.turnBanner(`${monthNames[G.month]} ${G.year}`);
|
||||
const replays = sim.endTurn();
|
||||
syncMapToState();
|
||||
ui.refreshAll();
|
||||
|
||||
// summarize notable AI captures against the player
|
||||
const notable = replays.filter(r => r.captured || r.genKilled);
|
||||
for (const r of notable.slice(0, 3)) {
|
||||
if (r.captured && CITY(r.captured)?.owner !== G.playerFaction) ui.toast(`${CITY(r.captured)?.name} has fallen to ${F(r.meta.atkFaction)?.name}.`, "bad");
|
||||
}
|
||||
|
||||
if (G.gameOver) { showEndScreen(); return; }
|
||||
processEventQueue();
|
||||
} finally {
|
||||
busy = false;
|
||||
$("#btn-endturn").classList.remove("busy");
|
||||
}
|
||||
}, 30);
|
||||
};
|
||||
|
||||
function processEventQueue() {
|
||||
if (!ui.hasEvents()) return;
|
||||
ui.showNextEvent((choice) => {
|
||||
audio.click();
|
||||
if (choice?.effect) {
|
||||
const res = applyEffect(choice.effect);
|
||||
if (res?.msg) ui.toast(res.msg, res.fail ? "bad" : "good");
|
||||
}
|
||||
ui.refreshAll();
|
||||
processEventQueue();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- END SCREEN ----------------
|
||||
function showEndScreen() {
|
||||
const pf = playerFaction();
|
||||
const ruler = GEN(pf.leader);
|
||||
const victoryText = {
|
||||
conquest: "You rule the Middle Kingdom. The wars are over — what you have conquered, your descendants must keep.",
|
||||
dominion: "Every rival banner lies folded in the dust. China answers to one throne: yours.",
|
||||
emperor: "You received the Mandate of Heaven. A new dynasty dawns, and the chroniclers ready their brushes.",
|
||||
challenge_survived: "One hundred days of chaos survived. Your banner still flies — the world noticed.",
|
||||
dead: "Your line is extinguished. Other men will write this history.",
|
||||
}[G.victory] ?? "The age moves on.";
|
||||
$("#end-title").textContent = G.victory === "dead" ? "YOUR TALE ENDS" : "THE CHRONICLE OF YOUR DYNASTY";
|
||||
$("#end-dynasty").textContent = pf.name.replace(/^House of /, "") + " 帝國";
|
||||
const lines = [`Year ${G.year}. ${victoryText}`, ""];
|
||||
for (const c of [...G.chronicle].reverse().slice(-24)) {
|
||||
lines.push(`${c.y}/${String(c.m).padStart(2, "0")} — ${c.text}`);
|
||||
}
|
||||
$("#end-chronicle").textContent = lines.join("\n");
|
||||
const s = G.stats;
|
||||
$("#end-stats").innerHTML = `
|
||||
<div class="es-item"><b>${pf.cities.length}</b><span>FINAL CITIES</span></div>
|
||||
<div class="es-item"><b>${s.battlesWon}</b><span>BATTLES WON</span></div>
|
||||
<div class="es-item"><b>${s.battlesLost}</b><span>BATTLES LOST</span></div>
|
||||
<div class="es-item"><b>${s.recruited}</b><span>OFFICERS RECRUITED</span></div>
|
||||
<div class="es-item"><b>${s.lostGenerals}</b><span>OFFICERS LOST</span></div>
|
||||
<div class="es-item"><b>${s.executed}</b><span>EXECUTIONS</span></div>
|
||||
<div class="es-item"><b>${s.warsDeclared}</b><span>WARS DECLARED</span></div>
|
||||
<div class="es-item"><b>${s.betrayals}</b><span>OATHS BROKEN</span></div>
|
||||
<div class="es-item"><b>${G.year - D.START_YEAR}</b><span>YEARS RULED</span></div>`;
|
||||
audio.playMusic("victory");
|
||||
show("#end-screen");
|
||||
}
|
||||
|
||||
// ================= HOOKS =================
|
||||
function makeHooks() {
|
||||
const sync = () => { syncMapToState(); };
|
||||
return {
|
||||
onCourtAction: (act, genId) => sim.courtAction(act, genId),
|
||||
onCourtAction2: act => sim.courtAction(act, playerFaction().leader),
|
||||
onBuild: (cid, key) => sim.buildBuilding(cid, key),
|
||||
onRecruit: (cid, type, n) => sim.recruitToGarrison(CITY(cid), type, n),
|
||||
onRaiseArmy: (cid, genId, troops) => { const r = sim.raiseArmyFromCity(CITY(cid), genId, troops); if (r.ok) setTimeout(sync, 0); return r; },
|
||||
onMerge: ids => { sim.mergeArmies(ids); setTimeout(sync, 0); },
|
||||
onDisband: id => { sim.disbandArmy(id); setTimeout(sync, 0); },
|
||||
onDiplo: (act, facId) => sim.diploAction(act, facId),
|
||||
onPrisoner: (act, genId) => sim.prisonerAction(act, genId),
|
||||
onCityDetail: cid => sim.cityDetail(cid),
|
||||
onFocusCity: cid => { const c = CITY(cid); map.focusOn(c.x, c.z, 55); },
|
||||
onSelectArmy: id => { selectedArmyId = id; const a = G.armies[id]; if (!a) return; const node = map.armyNodes.get(id); if (node) { map.setSelected({ type: "army", id, x: node.position.x, z: node.position.z }); selectedArmyId = id; } },
|
||||
onProtectEmperor: () => { sim.protectEmperor(); ui.toast("You take the Son of Heaven under your protection.", "epic"); },
|
||||
onProclaimEmperor: () => { sim.proclaimEmperor(); ui.toast("Heaven trembles — a new dynasty is proclaimed!", "epic"); },
|
||||
dragRotates: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ================= KEYBOARD =================
|
||||
window.addEventListener("keydown", e => {
|
||||
if ($("#game-screen").classList.contains("hidden")) return;
|
||||
const typing = /input|textarea|select/i.test(document.activeElement?.tagName ?? "");
|
||||
if (typing) return;
|
||||
// event modal open? number keys pick choices
|
||||
if (!$("#event-modal").classList.contains("hidden")) {
|
||||
if (/^[1-9]$/.test(e.key)) {
|
||||
const choices = $$("#event-choices .event-choice");
|
||||
choices[+e.key - 1]?.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const battleOpen = !$("#battle-overlay").classList.contains("hidden");
|
||||
const modalOpen = !!$("#temp-modal");
|
||||
if (e.key === "Escape") {
|
||||
closeModal();
|
||||
$("#side-panel").classList.add("hidden");
|
||||
selectedArmyId = null;
|
||||
map.setSelected(null);
|
||||
} else if ((e.key === "e" || e.key === "E") && !modalOpen && !battleOpen &&
|
||||
!busy && !G.gameOver && $("#event-modal").classList.contains("hidden")) {
|
||||
$("#btn-endturn").click();
|
||||
}
|
||||
});
|
||||
|
||||
// ================= LOOP =================
|
||||
let lastT = performance.now();
|
||||
function loop(t) {
|
||||
requestAnimationFrame(loop);
|
||||
const dt = Math.min(0.05, (t - lastT) / 1000);
|
||||
lastT = t;
|
||||
const gameVisible = !$("#game-screen").classList.contains("hidden");
|
||||
const battleOpen = !$("#battle-overlay").classList.contains("hidden");
|
||||
if (gameVisible && map && !battleOpen) {
|
||||
// pause strategic map while the battle scene owns the screen
|
||||
map.update(dt);
|
||||
renderLabels();
|
||||
} else {
|
||||
lastT = t; // keep dt sane when resuming
|
||||
}
|
||||
if (battleOpen && battleScene) {
|
||||
battleScene.update(dt);
|
||||
}
|
||||
}
|
||||
|
||||
// ================= BOOT =================
|
||||
// debug/testing handle
|
||||
window.__WFLD = { get G() { return G; }, sim, D, F, CITY, GEN, playReplays: list => playReplayRecords(list), ui: () => ui, get battleScene() { return battleScene; }, showEndScreen, getMap: () => map };
|
||||
|
||||
window.addEventListener("error", e => console.error(e.error ?? e.message));
|
||||
|
||||
initTitle();
|
||||
show("#title-screen");
|
||||
requestAnimationFrame(loop);
|
||||
+766
@@ -0,0 +1,766 @@
|
||||
// ============================================================
|
||||
// MAP3D — stylized ink-painting China in Three.js
|
||||
// ============================================================
|
||||
import * as THREE from "./vendor/three.module.js";
|
||||
import { MAP_ROWS, HEX_R, hexToWorld, PROVINCES } from "./data.js";
|
||||
|
||||
const HEX_W = Math.sqrt(3) * HEX_R;
|
||||
const TILE_H = 1.6; // base tile thickness
|
||||
const SEA_Y = -2.2;
|
||||
|
||||
export class GameMap {
|
||||
constructor(canvas, hooks = {}) {
|
||||
this.canvas = canvas;
|
||||
this.hooks = hooks;
|
||||
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
// low-quality mode for software renderers
|
||||
try {
|
||||
const gl = this.renderer.getContext();
|
||||
const dbg = gl.getExtension("WEBGL_debug_renderer_info");
|
||||
const gpu = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : "";
|
||||
this.lowQuality = /swiftshader|llvmpipe|software/i.test(String(gpu));
|
||||
} catch { this.lowQuality = false; }
|
||||
if (this.lowQuality) console.log("[map] software renderer detected — reducing quality");
|
||||
this.renderer.setPixelRatio(this.lowQuality ? 1 : Math.min(devicePixelRatio, 1.75));
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
this.renderer.toneMappingExposure = 1.12;
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.fog = new THREE.FogExp2(0x141017, 0.0055);
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(46, 1, 0.5, 900);
|
||||
this.camTarget = new THREE.Vector3(60, 0, 55);
|
||||
this.camGoal = { dist: 95, theta: -Math.PI / 2 + 0.35, phi: 0.98 };
|
||||
this.camCur = { dist: 130, theta: this.camGoal.theta, phi: 1.12 };
|
||||
|
||||
this.raycaster = new THREE.Raycaster();
|
||||
this.pointer = new THREE.Vector2();
|
||||
this.hovered = null;
|
||||
this.selected = null;
|
||||
this.labelPositions = [];
|
||||
|
||||
this.cityNodes = new Map(); // cityId -> group
|
||||
this.armyNodes = new Map(); // armyId -> group
|
||||
this.hexIndex = []; // instanceId -> hex info
|
||||
|
||||
this._drag = null;
|
||||
this._frame = 0;
|
||||
this._keys = new Set();
|
||||
this._time = 0;
|
||||
|
||||
this.buildLights();
|
||||
this.buildGround();
|
||||
this.buildHexes();
|
||||
this.buildRivers();
|
||||
this.buildDecor();
|
||||
|
||||
this.bindInput();
|
||||
this.resize();
|
||||
window.addEventListener("resize", () => this.resize());
|
||||
}
|
||||
|
||||
// ---------------- scene construction ----------------
|
||||
buildLights() {
|
||||
const hemi = new THREE.HemisphereLight(0xcfd8e8, 0x2a2018, 0.75);
|
||||
this.scene.add(hemi);
|
||||
const sun = new THREE.DirectionalLight(0xffd9a0, 1.5);
|
||||
sun.position.set(-70, 90, -40);
|
||||
sun.castShadow = !this.lowQuality;
|
||||
sun.shadow.mapSize.set(1024, 1024);
|
||||
sun.shadow.camera.left = -110; sun.shadow.camera.right = 110;
|
||||
sun.shadow.camera.top = 110; sun.shadow.camera.bottom = -110;
|
||||
sun.shadow.camera.far = 320;
|
||||
sun.shadow.bias = -0.0006;
|
||||
this.scene.add(sun);
|
||||
this.scene.add(new THREE.AmbientLight(0x40342a, 0.5));
|
||||
// warm rim from the east
|
||||
const rim = new THREE.DirectionalLight(0xd86a4a, 0.35);
|
||||
rim.position.set(80, 30, 60);
|
||||
this.scene.add(rim);
|
||||
}
|
||||
|
||||
makeParchmentTexture(size = 512, base = "#d9c69a") {
|
||||
const cv = document.createElement("canvas");
|
||||
cv.width = cv.height = size;
|
||||
const ctx = cv.getContext("2d");
|
||||
ctx.fillStyle = base;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
// mottling
|
||||
for (let i = 0; i < 2600; i++) {
|
||||
const x = Math.random() * size, y = Math.random() * size;
|
||||
const r = 1 + Math.random() * 22;
|
||||
const a = 0.015 + Math.random() * 0.05;
|
||||
ctx.fillStyle = Math.random() < 0.5 ? `rgba(120,90,50,${a})` : `rgba(255,244,214,${a})`;
|
||||
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
// fibers
|
||||
for (let i = 0; i < 300; i++) {
|
||||
ctx.strokeStyle = `rgba(100,80,50,${0.02 + Math.random() * 0.04})`;
|
||||
ctx.lineWidth = 0.6;
|
||||
const x = Math.random() * size, y = Math.random() * size;
|
||||
ctx.beginPath(); ctx.moveTo(x, y);
|
||||
ctx.lineTo(x + (Math.random() - 0.5) * 40, y + (Math.random() - 0.5) * 40);
|
||||
ctx.stroke();
|
||||
}
|
||||
const tex = new THREE.CanvasTexture(cv);
|
||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
return tex;
|
||||
}
|
||||
|
||||
buildGround() {
|
||||
// sea plane
|
||||
const seaMat = new THREE.MeshStandardMaterial({ color: 0x18222e, roughness: 0.55, metalness: 0.1 });
|
||||
const sea = new THREE.Mesh(new THREE.PlaneGeometry(1400, 1400), seaMat);
|
||||
sea.rotation.x = -Math.PI / 2;
|
||||
sea.position.y = SEA_Y;
|
||||
this.scene.add(sea);
|
||||
|
||||
// parchment underlay beneath hexes
|
||||
const tex = this.makeParchmentTexture(512, "#cdb488");
|
||||
tex.repeat.set(14, 14);
|
||||
const groundMat = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.92 });
|
||||
const ground = new THREE.Mesh(new THREE.PlaneGeometry(400, 400), groundMat);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.position.y = -TILE_H / 2 - 0.05;
|
||||
ground.receiveShadow = true;
|
||||
this.scene.add(ground);
|
||||
this.groundMesh = ground;
|
||||
|
||||
// sky dome with gradient
|
||||
const skyGeo = new THREE.SphereGeometry(700, 24, 16);
|
||||
const skyMat = new THREE.ShaderMaterial({
|
||||
side: THREE.BackSide,
|
||||
uniforms: {},
|
||||
vertexShader: `varying vec3 vP; void main(){ vP = position; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
|
||||
fragmentShader: `
|
||||
varying vec3 vP;
|
||||
void main(){
|
||||
float h = normalize(vP).y;
|
||||
vec3 top = vec3(0.07,0.06,0.11);
|
||||
vec3 mid = vec3(0.24,0.15,0.13);
|
||||
vec3 hor = vec3(0.85,0.62,0.38);
|
||||
float t = clamp(h,-0.1,1.0);
|
||||
vec3 c = mix(mix(hor,mid,smoothstep(-0.02,0.18,t)), top, smoothstep(0.15,0.7,t));
|
||||
gl_FragColor = vec4(c,1.0);
|
||||
}`,
|
||||
});
|
||||
this.scene.add(new THREE.Mesh(skyGeo, skyMat));
|
||||
}
|
||||
|
||||
buildHexes() {
|
||||
// collect land hexes
|
||||
const rows = MAP_ROWS;
|
||||
this.hexes = [];
|
||||
for (let r = 0; r < rows.length; r++) {
|
||||
for (let c = 0; c < rows[r].length; c++) {
|
||||
const ch = rows[r][c];
|
||||
if (ch === ".") continue;
|
||||
const [x, z] = hexToWorld(c, r);
|
||||
this.hexes.push({ col: c, row: r, ch, prov: ch.toLowerCase(), mountain: ch !== ch.toLowerCase(), x, z });
|
||||
}
|
||||
}
|
||||
const n = this.hexes.length;
|
||||
|
||||
// hex prisms (instanced)
|
||||
const geo = new THREE.CylinderGeometry(HEX_R * 0.96, HEX_R * 0.96, TILE_H, 6);
|
||||
geo.rotateY(Math.PI / 6);
|
||||
const mat = new THREE.MeshStandardMaterial({ roughness: 0.9, metalness: 0.02, flatShading: true });
|
||||
this.hexMesh = new THREE.InstancedMesh(geo, mat, n);
|
||||
this.hexMesh.receiveShadow = true;
|
||||
this.hexMesh.castShadow = false;
|
||||
this.hexMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
|
||||
const m4 = new THREE.Matrix4();
|
||||
const color = new THREE.Color();
|
||||
this.hexIndex = [];
|
||||
this.hexTop = new Map(); // "col,row" -> top Y
|
||||
let noiseSeed = 7;
|
||||
const rnd = () => { noiseSeed = (noiseSeed * 16807) % 2147483647; return (noiseSeed & 0xffff) / 0xffff; };
|
||||
|
||||
this.hexes.forEach((h, i) => {
|
||||
const hgt = TILE_H + (h.mountain ? 1.1 : 0) + rnd() * 0.24;
|
||||
h.topY = hgt;
|
||||
this.hexTop.set(h.col + "," + h.row, hgt);
|
||||
m4.makeScale(1, hgt / TILE_H, 1);
|
||||
m4.setPosition(h.x, hgt / 2, h.z);
|
||||
this.hexMesh.setMatrixAt(i, m4);
|
||||
const tint = PROVINCES[h.prov].tint;
|
||||
color.setHex(tint).multiplyScalar(0.82 + rnd() * 0.25);
|
||||
if (h.mountain) color.lerp(new THREE.Color(0x8a785f), 0.45);
|
||||
this.hexMesh.setColorAt(i, color);
|
||||
h.instId = i;
|
||||
this.hexIndex.push(h);
|
||||
});
|
||||
this.hexMesh.userData.map = this;
|
||||
this.scene.add(this.hexMesh);
|
||||
|
||||
// province border lines (ink strokes)
|
||||
const pts = [];
|
||||
const nbrsOf = (c, r) => { const o = r % 2; return [[c - 1, r], [c + 1, r], [c + o - 1, r - 1], [c + o, r - 1], [c + o - 1, r + 1], [c + o, r + 1]]; };
|
||||
const at = (c, r) => (r < 0 || r >= rows.length || c < 0 || c >= (rows[r]?.length ?? 0)) ? "." : rows[r][c];
|
||||
const R2 = HEX_R * 0.985;
|
||||
// corners of pointy-top hex: angles 60k-30 degrees
|
||||
const corners = [];
|
||||
for (let k = 0; k < 6; k++) {
|
||||
const ang = Math.PI / 180 * (60 * k - 30);
|
||||
corners.push([R2 * Math.cos(ang), R2 * Math.sin(ang)]);
|
||||
}
|
||||
// neighbor index -> shared edge corner pair
|
||||
// nbrs order: 0=W 1=E 2=NW 3=NE 4=SW 5=SE
|
||||
const edgeFor = [[3, 4], [0, 1], [4, 5], [5, 0], [2, 3], [1, 2]];
|
||||
for (const h of this.hexes) {
|
||||
const realNbrs = nbrsOf(h.col, h.row);
|
||||
for (let ni = 0; ni < 6; ni++) {
|
||||
const nb = realNbrs[ni];
|
||||
const q = at(nb[0], nb[1]);
|
||||
if (q === "." || q.toLowerCase() !== h.prov) {
|
||||
const ci = edgeFor[ni];
|
||||
const p1 = corners[ci[0]], p2 = corners[ci[1]];
|
||||
pts.push(new THREE.Vector3(h.x + p1[0], TILE_H + 0.12, h.z + p1[1]));
|
||||
pts.push(new THREE.Vector3(h.x + p2[0], TILE_H + 0.12, h.z + p2[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
const lineGeo = new THREE.BufferGeometry().setFromPoints(pts);
|
||||
const lineMat = new THREE.LineBasicMaterial({ color: 0x3a2c1a, transparent: true, opacity: 0.55 });
|
||||
this.borders = new THREE.LineSegments(lineGeo, lineMat);
|
||||
this.scene.add(this.borders);
|
||||
}
|
||||
|
||||
buildRivers() {
|
||||
import("./data.js").then(({ RIVERS }) => {
|
||||
for (const river of RIVERS) {
|
||||
const pts = river.pts.map(([c, r]) => {
|
||||
const [x, z] = hexToWorld(c, r);
|
||||
return new THREE.Vector3(x, (this.hexTop?.get(c + "," + r) ?? TILE_H) + 0.06, z);
|
||||
});
|
||||
const curve = new THREE.CatmullRomCurve3(pts);
|
||||
const geo = new THREE.TubeGeometry(curve, river.pts.length * 6, 0.42, 6, false);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0x3d5a6e, roughness: 0.35, metalness: 0.25,
|
||||
emissive: 0x16222c, emissiveIntensity: 0.6,
|
||||
});
|
||||
const tube = new THREE.Mesh(geo, mat);
|
||||
this.scene.add(tube);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
buildDecor() {
|
||||
// mountains on mountainous hexes + province mount bias
|
||||
const coneGeo = new THREE.ConeGeometry(1, 1, 5);
|
||||
coneGeo.translate(0, 0.5, 0);
|
||||
const rockMat = new THREE.MeshStandardMaterial({ color: 0x6b5d4a, roughness: 0.95, flatShading: true });
|
||||
const snowMat = new THREE.MeshStandardMaterial({ color: 0xe8e2d2, roughness: 0.8, flatShading: true });
|
||||
const mounts = [];
|
||||
const LQ = this.lowQuality;
|
||||
let seed = 13;
|
||||
const rnd = () => { seed = (seed * 16807) % 2147483647; return (seed & 0xffff) / 0xffff; };
|
||||
for (const h of this.hexes) {
|
||||
const bias = PROVINCES[h.prov].mount;
|
||||
let count = 0;
|
||||
if (h.mountain) count = LQ ? 1 + Math.floor(rnd() * 2) : 2 + Math.floor(rnd() * 3);
|
||||
else if (rnd() < bias * 0.28) count = 1 + Math.floor(rnd() * 2);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const hh = 2.2 + rnd() * (h.mountain ? 6.5 : 3.2);
|
||||
const ww = 1.6 + rnd() * 2.6;
|
||||
mounts.push({ x: h.x + (rnd() - 0.5) * HEX_R * 1.4, z: h.z + (rnd() - 0.5) * HEX_R * 1.4, y: h.topY, hh, ww, snowy: hh > 6.4 });
|
||||
}
|
||||
}
|
||||
const inst = new THREE.InstancedMesh(coneGeo, rockMat, mounts.length);
|
||||
inst.castShadow = true; inst.receiveShadow = true;
|
||||
const m4 = new THREE.Matrix4();
|
||||
const q = new THREE.Quaternion(), e = new THREE.Euler();
|
||||
mounts.forEach((mt, i) => {
|
||||
e.set((rnd() - 0.5) * 0.14, rnd() * Math.PI, (rnd() - 0.5) * 0.14);
|
||||
q.setFromEuler(e);
|
||||
m4.compose(new THREE.Vector3(mt.x, mt.y - 0.25, mt.z), q, new THREE.Vector3(mt.ww, mt.hh, mt.ww));
|
||||
inst.setMatrixAt(i, m4);
|
||||
this.scene.add(inst);
|
||||
});
|
||||
this.mountains = inst;
|
||||
// snow caps as second instanced mesh slightly smaller cones on top of tall peaks
|
||||
const tall = mounts.filter(m => m.snowy);
|
||||
const snowInst = new THREE.InstancedMesh(coneGeo, snowMat, tall.length);
|
||||
tall.forEach((mt, i) => {
|
||||
e.set(0, 0, 0); q.setFromEuler(e);
|
||||
const s = mt.ww * 0.34, sh = mt.hh * 0.3;
|
||||
m4.compose(new THREE.Vector3(mt.x, mt.y - 0.25 + mt.hh - sh * 0.9, mt.z), q, new THREE.Vector3(s, sh, s));
|
||||
snowInst.setMatrixAt(i, m4);
|
||||
});
|
||||
this.scene.add(snowInst);
|
||||
|
||||
// trees (southern lusher provinces)
|
||||
const treeGeo = new THREE.ConeGeometry(0.55, 1.5, 5);
|
||||
treeGeo.translate(0, 0.75, 0);
|
||||
const trunkGeo = new THREE.CylinderGeometry(0.09, 0.12, 0.5, 4);
|
||||
trunkGeo.translate(0, 0.25, 0);
|
||||
const leafMat = new THREE.MeshStandardMaterial({ color: 0x4a6239, roughness: 0.9, flatShading: true });
|
||||
const trees = [];
|
||||
for (const h of this.hexes) {
|
||||
if (h.mountain) continue;
|
||||
const lush = ["g", "n", "h"].includes(h.prov) ? 0.75 : ["u", "x"].includes(h.prov) ? 0.35 : 0.15;
|
||||
let cnt = 0;
|
||||
const rr = rnd();
|
||||
if (rr < lush) cnt = (LQ ? 1 : 2) + Math.floor(rnd() * (LQ ? 2 : 5));
|
||||
for (let i = 0; i < cnt; i++) {
|
||||
trees.push({ x: h.x + (rnd() - 0.5) * HEX_R * 1.5, z: h.z + (rnd() - 0.5) * HEX_R * 1.5, y: h.topY, s: 0.6 + rnd() * 0.9 });
|
||||
}
|
||||
}
|
||||
const treeInst = new THREE.InstancedMesh(treeGeo, leafMat, trees.length);
|
||||
const trunks = new THREE.InstancedMesh(trunkGeo, rockMat, trees.length);
|
||||
trees.forEach((t, i) => {
|
||||
m4.compose(new THREE.Vector3(t.x, t.y - 0.22, t.z), q.identity(), new THREE.Vector3(t.s, t.s, t.s));
|
||||
treeInst.setMatrixAt(i, m4);
|
||||
m4.compose(new THREE.Vector3(t.x, t.y - 0.22, t.z), q, new THREE.Vector3(1, 1, 1));
|
||||
trunks.setMatrixAt(i, m4);
|
||||
});
|
||||
this.scene.add(treeInst, trunks);
|
||||
|
||||
// drifting dust motes
|
||||
const dustCount = this.lowQuality ? 140 : 420;
|
||||
const dustPos = new Float32Array(dustCount * 3);
|
||||
for (let i = 0; i < dustCount; i++) {
|
||||
dustPos[i * 3] = (Math.random() - 0.5) * 220 + 60;
|
||||
dustPos[i * 3 + 1] = Math.random() * 26 + 2;
|
||||
dustPos[i * 3 + 2] = (Math.random() - 0.5) * 200 + 55;
|
||||
}
|
||||
const dustGeo = new THREE.BufferGeometry();
|
||||
dustGeo.setAttribute("position", new THREE.BufferAttribute(dustPos, 3));
|
||||
const dustMat = new THREE.PointsMaterial({
|
||||
color: 0xd8c9a0, size: 0.5, transparent: true, opacity: 0.32,
|
||||
depthWrite: false, sizeAttenuation: true,
|
||||
});
|
||||
this.dust = new THREE.Points(dustGeo, dustMat);
|
||||
this.scene.add(this.dust);
|
||||
}
|
||||
|
||||
// ---------------- cities ----------------
|
||||
buildCityNode(city, ownerColor) {
|
||||
const g = new THREE.Group();
|
||||
const topY = this.hexTop?.get(city.col + "," + city.row) ?? TILE_H;
|
||||
g.position.set(city.x, topY - 0.18, city.z);
|
||||
const col = new THREE.Color(ownerColor || "#8a8a82");
|
||||
const woodDark = new THREE.MeshStandardMaterial({ color: 0x4a3626, roughness: 0.85 });
|
||||
const wallMat = new THREE.MeshStandardMaterial({ color: 0xbfae8d, roughness: 0.9 });
|
||||
const roofMat = new THREE.MeshStandardMaterial({ color: col.clone().multiplyScalar(0.9), roughness: 0.6, emissive: col.clone().multiplyScalar(0.12), flatShading: true });
|
||||
const goldMat = new THREE.MeshStandardMaterial({ color: 0xc8a24e, roughness: 0.4, metalness: 0.6 });
|
||||
|
||||
const tier = city.tier || 1;
|
||||
|
||||
// platform
|
||||
const plat = new THREE.Mesh(new THREE.BoxGeometry(HEX_R * 1.5, 0.5, HEX_R * 1.5), woodDark);
|
||||
plat.position.y = 0.25; plat.castShadow = true; plat.receiveShadow = true;
|
||||
g.add(plat);
|
||||
|
||||
// wall ring
|
||||
const wall = new THREE.Mesh(new THREE.CylinderGeometry(HEX_R * 0.72, HEX_R * 0.76, 1.1 + tier * 0.22, 8), wallMat);
|
||||
wall.position.y = 0.55 + (1.1 + tier * 0.22) / 2;
|
||||
wall.castShadow = true; wall.receiveShadow = true;
|
||||
g.add(wall);
|
||||
|
||||
// central keep: box + pyramid roofs stacked by tier
|
||||
const keepH = 1.6 + tier * 0.5;
|
||||
const keep = new THREE.Mesh(new THREE.BoxGeometry(2.1, keepH, 2.1), woodDark);
|
||||
keep.position.y = 1.1 + keepH / 2;
|
||||
keep.castShadow = true;
|
||||
g.add(keep);
|
||||
for (let lv = 0; lv < Math.min(tier, 4); lv++) {
|
||||
const rw = 1.9 - lv * 0.32;
|
||||
const roof = new THREE.Mesh(new THREE.ConeGeometry(rw, 0.75, 4), roofMat);
|
||||
roof.rotation.y = Math.PI / 4;
|
||||
roof.position.y = 1.1 + keepH * (0.35 + lv * 0.24) + 0.4;
|
||||
roof.castShadow = true;
|
||||
g.add(roof);
|
||||
}
|
||||
// finial
|
||||
const fin = new THREE.Mesh(new THREE.SphereGeometry(0.16, 6, 6), goldMat);
|
||||
fin.position.y = 1.1 + keepH + 1.4;
|
||||
g.add(fin);
|
||||
|
||||
// corner towers for larger tiers
|
||||
if (tier >= 3) {
|
||||
for (const [sx, sz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
|
||||
const tw = new THREE.Mesh(new THREE.BoxGeometry(0.8, 2.2 + tier * 0.3, 0.8), wallMat);
|
||||
tw.position.set(sx * HEX_R * 0.62, 1.1 + (2.2 + tier * 0.3) / 2, sz * HEX_R * 0.62);
|
||||
tw.castShadow = true;
|
||||
g.add(tw);
|
||||
const troof = new THREE.Mesh(new THREE.ConeGeometry(0.72, 0.6, 4), roofMat);
|
||||
troof.rotation.y = Math.PI / 4;
|
||||
troof.position.set(sx * HEX_R * 0.62, 1.1 + (2.2 + tier * 0.3) + 0.3, sz * HEX_R * 0.62);
|
||||
g.add(troof);
|
||||
}
|
||||
}
|
||||
// houses for big cities
|
||||
if (tier >= 2) {
|
||||
const houseMat = new THREE.MeshStandardMaterial({ color: 0x8d7a5e, roughness: 0.9 });
|
||||
const nHouses = tier * 3;
|
||||
for (let i = 0; i < nHouses; i++) {
|
||||
const ang = (i / nHouses) * Math.PI * 2 + 0.4;
|
||||
const rad = HEX_R * (0.95 + Math.random() * 0.25);
|
||||
const hx = Math.cos(ang) * rad, hz = Math.sin(ang) * rad;
|
||||
const hs = 0.5 + Math.random() * 0.35;
|
||||
const house = new THREE.Mesh(new THREE.BoxGeometry(hs, hs * 0.9, hs), houseMat);
|
||||
house.position.set(hx, hs * 0.45, hz);
|
||||
house.rotation.y = Math.random() * Math.PI;
|
||||
house.castShadow = true;
|
||||
g.add(house);
|
||||
const hr = new THREE.Mesh(new THREE.ConeGeometry(hs * 0.8, hs * 0.55, 4), roofMat);
|
||||
hr.rotation.y = Math.PI / 4;
|
||||
hr.position.set(hx, hs * 0.9 + hs * 0.27, hz);
|
||||
g.add(hr);
|
||||
}
|
||||
}
|
||||
|
||||
// banner pole + waving flag (owner colored)
|
||||
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 4.6, 4), woodDark);
|
||||
pole.position.set(HEX_R * 0.95, 2.3, 0);
|
||||
g.add(pole);
|
||||
const flagGeo = new THREE.PlaneGeometry(1.7, 1.0, 10, 3);
|
||||
flagGeo.translate(0.85, 0, 0);
|
||||
const flagMat = new THREE.MeshStandardMaterial({ color: col, side: THREE.DoubleSide, roughness: 0.8, emissive: col.clone().multiplyScalar(0.15) });
|
||||
const flag = new THREE.Mesh(flagGeo, flagMat);
|
||||
flag.position.set(HEX_R * 0.95, 4.1, 0);
|
||||
flag.castShadow = true;
|
||||
g.add(flag);
|
||||
g.userData.flag = flag;
|
||||
|
||||
// glow sprite at night-ish tone
|
||||
const glowTex = makeGlowTexture();
|
||||
const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTex, color: col, transparent: true, opacity: 0.5, depthWrite: false }));
|
||||
glow.scale.set(4, 4, 1);
|
||||
glow.position.y = 1.4;
|
||||
g.add(glow);
|
||||
|
||||
g.traverse(o => { if (o.isMesh) o.userData.cityId = city.id; });
|
||||
this.scene.add(g);
|
||||
this.cityNodes.set(city.id, g);
|
||||
return g;
|
||||
}
|
||||
|
||||
syncCities(cities, factionColors) {
|
||||
for (const c of Object.values(cities)) {
|
||||
let node = this.cityNodes.get(c.id);
|
||||
const wantTier = c.devTier || 1;
|
||||
if (!node || node.userData.tier !== wantTier || node.userData.ownerKey !== `${c.owner}|${factionColors[c.owner]}`) {
|
||||
if (node) { this.scene.remove(node); disposeGroup(node); }
|
||||
node = this.buildCityNode(c, factionColors[c.owner]);
|
||||
node.userData.tier = wantTier;
|
||||
node.userData.ownerKey = `${c.owner}|${factionColors[c.owner]}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refreshOwnership(factionColors) {
|
||||
// recolor hex instances by owner
|
||||
const color = new THREE.Color();
|
||||
for (const h of this.hexes) {
|
||||
const ownerFacId = this.provOwner?.[h.prov];
|
||||
const base = new THREE.Color(PROVINCES[h.prov].tint).multiplyScalar(0.82 + ((h.col * 31 + h.row * 17) % 23) / 90);
|
||||
if (h.mountain) base.lerp(new THREE.Color(0x8a785f), 0.45);
|
||||
if (ownerFacId && factionColors[ownerFacId]) {
|
||||
const fc = new THREE.Color(factionColors[ownerFacId]);
|
||||
base.lerp(fc, h.mountain ? 0.3 : 0.42);
|
||||
}
|
||||
this.hexMesh.setColorAt(h.instId, base);
|
||||
}
|
||||
this.hexMesh.instanceColor.needsUpdate = true;
|
||||
}
|
||||
|
||||
setProvinceOwners(mapProvToOwner) { this.provOwner = mapProvToOwner; }
|
||||
|
||||
// ---------------- armies ----------------
|
||||
syncArmies(armies, factionColors, provCentroid, provCityAnchor) {
|
||||
const seen = new Set();
|
||||
for (const a of Object.values(armies)) {
|
||||
seen.add(a.id);
|
||||
let node = this.armyNodes.get(a.id);
|
||||
if (!node) {
|
||||
node = this.buildArmyNode(a, factionColors[a.faction]);
|
||||
this.armyNodes.set(a.id, node);
|
||||
node.userData.armyId = a.id;
|
||||
}
|
||||
const anchor = provCityAnchor(a.prov) || provCentroid(a.prov);
|
||||
const ax = anchor[0], az = anchor[1];
|
||||
// multiple armies in same province: fan out
|
||||
const sameProv = Object.values(armies).filter(x => x.prov === a.prov);
|
||||
const idx = sameProv.indexOf(a);
|
||||
const off = sameProv.length > 1 ? [(idx - (sameProv.length - 1) / 2) * 3.4, idx % 2 ? 2.4 : -2.4] : [0, 0];
|
||||
node.position.set(ax + off[0], TILE_H + 0.05, az + off[1]);
|
||||
node.visible = true;
|
||||
const count = Object.values(a.troops).reduce((s, v) => s + v, 0);
|
||||
node.userData.count = count;
|
||||
const col = factionColors[a.faction] || "#888";
|
||||
if (node.userData.colorKey !== col) { recolorArmyNode(node, col); node.userData.colorKey = col; }
|
||||
}
|
||||
for (const [id, node] of this.armyNodes) {
|
||||
if (!seen.has(id)) { this.scene.remove(node); disposeGroup(node); this.armyNodes.delete(id); }
|
||||
}
|
||||
}
|
||||
|
||||
buildArmyNode(a, color) {
|
||||
const g = new THREE.Group();
|
||||
const col = new THREE.Color(color || "#888");
|
||||
// small soldier block
|
||||
const body = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1.5, 1.0, 2.2),
|
||||
new THREE.MeshStandardMaterial({ color: 0x54463a, roughness: 0.8 })
|
||||
);
|
||||
body.position.y = 0.5; body.castShadow = true;
|
||||
g.add(body);
|
||||
// banner
|
||||
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 3.2, 4), new THREE.MeshStandardMaterial({ color: 0x3a2c1a }));
|
||||
pole.position.set(0, 1.6, 0);
|
||||
g.add(pole);
|
||||
const flagGeo = new THREE.PlaneGeometry(1.3, 0.8, 8, 2);
|
||||
flagGeo.translate(0.65, 0, 0);
|
||||
const flagMat = new THREE.MeshStandardMaterial({ color: col, side: THREE.DoubleSide, emissive: col.clone().multiplyScalar(0.2), roughness: 0.8 });
|
||||
const flag = new THREE.Mesh(flagGeo, flagMat);
|
||||
flag.position.set(0, 3.0, 0);
|
||||
g.add(flag);
|
||||
g.userData.flag = flag;
|
||||
g.traverse(o => { if (o.isMesh) o.userData.armyHit = true; });
|
||||
this.scene.add(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
// ---------------- selection & hover visuals ----------------
|
||||
ensureRing() {
|
||||
if (this.ring) return;
|
||||
const geo = new THREE.RingGeometry(HEX_R * 0.9, HEX_R * 1.06, 32);
|
||||
geo.rotateX(-Math.PI / 2);
|
||||
const mat = new THREE.MeshBasicMaterial({ color: 0xffd97a, transparent: true, opacity: 0.85, side: THREE.DoubleSide });
|
||||
this.ring = new THREE.Mesh(geo, mat);
|
||||
this.scene.add(this.ring);
|
||||
}
|
||||
|
||||
setSelected(sel) {
|
||||
this.selected = sel; // {type:'city'|'army'|'prov', id, x,z}
|
||||
this.ensureRing();
|
||||
if (!sel) { this.ring.visible = false; return; }
|
||||
this.ring.visible = true;
|
||||
this.ring.position.set(sel.x, TILE_H + 0.16, sel.z);
|
||||
}
|
||||
|
||||
setHover(sel) {
|
||||
this.hovered = sel;
|
||||
this.canvas.style.cursor = sel ? "pointer" : "grab";
|
||||
}
|
||||
|
||||
focusOn(x, z, dist) {
|
||||
this.camTarget.set(x, 0, z);
|
||||
if (dist) this.camGoal.dist = dist;
|
||||
}
|
||||
|
||||
// ---------------- input ----------------
|
||||
bindInput() {
|
||||
const el = this.canvas;
|
||||
el.addEventListener("pointerdown", (e) => {
|
||||
this._drag = { x: e.clientX, y: e.clientY, btn: e.button, moved: false };
|
||||
el.setPointerCapture(e.pointerId);
|
||||
});
|
||||
el.addEventListener("pointermove", (e) => {
|
||||
if (this._drag) {
|
||||
const dx = e.clientX - this._drag.x, dy = e.clientY - this._drag.y;
|
||||
if (Math.abs(dx) + Math.abs(dy) > 3) this._drag.moved = true;
|
||||
if (this._drag.btn === 0 && !this.hooks.dragRotates) {
|
||||
// left drag = rotate
|
||||
this.camGoal.theta -= dx * 0.005;
|
||||
this.camGoal.phi = clampNum(this.camGoal.phi + dy * 0.004, 0.32, 1.32);
|
||||
} else {
|
||||
// other buttons pan
|
||||
this.panBy(dx, dy);
|
||||
}
|
||||
this._drag.x = e.clientX; this._drag.y = e.clientY;
|
||||
} else {
|
||||
this.updatePointerHover(e);
|
||||
}
|
||||
});
|
||||
el.addEventListener("pointerup", (e) => {
|
||||
if (this._drag && !this._drag.moved && this._drag.btn === 0) {
|
||||
this.handleClick(e);
|
||||
}
|
||||
this._drag = null;
|
||||
});
|
||||
el.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
this.camGoal.dist = clampNum(this.camGoal.dist * (1 + Math.sign(e.deltaY) * 0.09), 20, 190);
|
||||
}, { passive: false });
|
||||
window.addEventListener("keydown", (e) => this._keys.add(e.key.toLowerCase()));
|
||||
window.addEventListener("keyup", (e) => this._keys.delete(e.key.toLowerCase()));
|
||||
}
|
||||
|
||||
panBy(dx, dy) {
|
||||
const scale = this.camCur.dist * 0.0016;
|
||||
const forward = new THREE.Vector3(Math.sin(this.camCur.theta), 0, Math.cos(this.camCur.theta));
|
||||
const right = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), forward).normalize();
|
||||
// grab-style: dragging right/down slides the MAP with the cursor,
|
||||
// always measured against the current viewing angle
|
||||
this.camTarget.addScaledVector(right, -dx * scale);
|
||||
this.camTarget.addScaledVector(forward, -dy * scale);
|
||||
this.camTarget.x = clampNum(this.camTarget.x, -30, 160);
|
||||
this.camTarget.z = clampNum(this.camTarget.z, -30, 150);
|
||||
}
|
||||
|
||||
pickAt(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
this.pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
this.pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
this.raycaster.setFromCamera(this.pointer, this.camera);
|
||||
// armies first
|
||||
const armyHits = this.raycaster.intersectObjects([...this.armyNodes.values()], true);
|
||||
if (armyHits.length) {
|
||||
let o = armyHits[0].object;
|
||||
while (o && !o.userData.armyId && o.parent) o = o.parent;
|
||||
if (o?.userData?.armyId) return { type: "army", id: o.userData.armyId };
|
||||
}
|
||||
const cityHits = this.raycaster.intersectObjects([...this.cityNodes.values()], true);
|
||||
if (cityHits.length) {
|
||||
let o = cityHits[0].object;
|
||||
while (o && !o.userData.cityId && o.parent) o = o.parent;
|
||||
if (o?.userData?.cityId) return { type: "city", id: o.userData.cityId };
|
||||
}
|
||||
const hexHits = this.raycaster.intersectObject(this.hexMesh);
|
||||
if (hexHits.length && hexHits[0].instanceId != null) {
|
||||
const h = this.hexIndex[hexHits[0].instanceId];
|
||||
if (h) return { type: "prov", id: h.prov, x: h.x, z: h.z };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
updatePointerHover(e) {
|
||||
const hit = this.pickAt(e);
|
||||
this.setHover(hit);
|
||||
this.hooks.onHover?.(hit, e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
handleClick(e) {
|
||||
const hit = this.pickAt(e);
|
||||
this.hooks.onClick?.(hit);
|
||||
}
|
||||
|
||||
// ---------------- frame update ----------------
|
||||
update(dt) {
|
||||
this._time += dt;
|
||||
// keyboard pan
|
||||
const spd = this.camCur.dist * dt * 0.9;
|
||||
const f = new THREE.Vector3(Math.sin(this.camCur.theta), 0, Math.cos(this.camCur.theta));
|
||||
const r = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), f).normalize();
|
||||
// all pan vectors are derived from the live camera azimuth (view-relative):
|
||||
// screen-forward F = (-sinθ, -cosθ); screen-right R = (cosθ, -sinθ)
|
||||
// f below equals -F, r equals +R.
|
||||
if (this._keys.has("w") || this._keys.has("arrowup")) this.camTarget.addScaledVector(f, -spd);
|
||||
if (this._keys.has("s") || this._keys.has("arrowdown")) this.camTarget.addScaledVector(f, spd);
|
||||
if (this._keys.has("a") || this._keys.has("arrowleft")) this.camTarget.addScaledVector(r, -spd);
|
||||
if (this._keys.has("d") || this._keys.has("arrowright")) this.camTarget.addScaledVector(r, spd);
|
||||
|
||||
// smooth camera
|
||||
const cur = this.camCur, goal = this.camGoal;
|
||||
cur.dist += (goal.dist - cur.dist) * Math.min(1, dt * 6);
|
||||
cur.theta += (goal.theta - cur.theta) * Math.min(1, dt * 6);
|
||||
cur.phi += (goal.phi - cur.phi) * Math.min(1, dt * 6);
|
||||
|
||||
const { dist, theta, phi } = cur;
|
||||
const cx = this.camTarget.x + dist * Math.sin(phi) * Math.sin(theta);
|
||||
const cz = this.camTarget.z + dist * Math.sin(phi) * Math.cos(theta);
|
||||
const cy = this.camTarget.y + dist * Math.cos(phi);
|
||||
this.camera.position.set(cx, cy, cz);
|
||||
this.camera.lookAt(this.camTarget.x, this.camTarget.y, this.camTarget.z);
|
||||
|
||||
// flags wave
|
||||
for (const node of [...this.cityNodes.values(), ...this.armyNodes.values()]) {
|
||||
const flag = node.userData.flag;
|
||||
if (!flag) continue;
|
||||
const pos = flag.geometry.attributes.position;
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
const x = pos.getX(i);
|
||||
const y = pos.getY(i);
|
||||
pos.setZ(i, Math.sin(this._time * 4 + x * 2.4 + node.position.x) * 0.09 * (x / 1.7 + 0.1));
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
if (!this.lowQuality && (this._frame++ & 15) === 0) flag.geometry.computeVertexNormals();
|
||||
}
|
||||
// ring pulse
|
||||
if (this.ring?.visible) {
|
||||
const s = 1 + Math.sin(this._time * 4) * 0.05;
|
||||
this.ring.scale.set(s, 1, s);
|
||||
}
|
||||
// dust drift
|
||||
if (this.dust) {
|
||||
this.dust.rotation.y += dt * 0.01;
|
||||
this.dust.position.y = Math.sin(this._time * 0.2) * 0.6;
|
||||
}
|
||||
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
// compute label screen positions for DOM overlay
|
||||
computeLabels(items) {
|
||||
// items: [{id,x,y,z}] world positions
|
||||
const out = [];
|
||||
const v = new THREE.Vector3();
|
||||
for (const it of items) {
|
||||
v.set(it.x, it.y, it.z).project(this.camera);
|
||||
const behind = v.z > 1;
|
||||
out.push({
|
||||
id: it.id,
|
||||
x: (v.x * 0.5 + 0.5) * this.canvas.clientWidth,
|
||||
y: (-v.y * 0.5 + 0.5) * this.canvas.clientHeight,
|
||||
visible: !behind,
|
||||
dist: this.camera.position.distanceTo(new THREE.Vector3(it.x, it.y, it.z)),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
resize() {
|
||||
const w = this.canvas.clientWidth || window.innerWidth;
|
||||
const h = this.canvas.clientHeight || window.innerHeight;
|
||||
this.renderer.setSize(w, h, false);
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
function clampNum(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
|
||||
|
||||
function makeGlowTexture() {
|
||||
const cv = document.createElement("canvas");
|
||||
cv.width = cv.height = 64;
|
||||
const ctx = cv.getContext("2d");
|
||||
const grad = ctx.createRadialGradient(32, 32, 2, 32, 32, 30);
|
||||
grad.addColorStop(0, "rgba(255,240,200,0.9)");
|
||||
grad.addColorStop(0.4, "rgba(255,220,150,0.25)");
|
||||
grad.addColorStop(1, "rgba(255,220,150,0)");
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, 64, 64);
|
||||
const tex = new THREE.CanvasTexture(cv);
|
||||
return tex;
|
||||
}
|
||||
|
||||
function disposeGroup(g) {
|
||||
g.traverse(o => {
|
||||
if (o.geometry) o.geometry.dispose();
|
||||
if (o.material) {
|
||||
if (Array.isArray(o.material)) o.material.forEach(m => m.dispose());
|
||||
else o.material.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function recolorArmyNode(node, color) {
|
||||
const c = new THREE.Color(color);
|
||||
node.traverse(o => {
|
||||
if (o.isMesh && o.material?.color) {
|
||||
if (o.material.color.getHexString() !== "54463a" && o.material.color.getHexString() !== "3a2c1a") {
|
||||
o.material.color.copy(c);
|
||||
if (o.material.emissive) o.material.emissive.copy(c).multiplyScalar(0.2);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
// ============================================================
|
||||
// WARLORD'S FATE — game state, RNG, save/load
|
||||
// ============================================================
|
||||
import * as D from "./data.js";
|
||||
|
||||
export let G = null;
|
||||
|
||||
export const uid = (() => { let n = 1000; return (p) => `${p}${++n}`; })();
|
||||
|
||||
// ---------- seeded RNG ----------
|
||||
export function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return function () {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
export function rand() { return G ? G.rand() : Math.random(); }
|
||||
export function randInt(a, b) { return a + Math.floor(rand() * (b - a + 1)); }
|
||||
export function pick(arr) { return arr[Math.floor(rand() * arr.length)]; }
|
||||
export function chance(p) { return rand() < p; }
|
||||
export function shuffle(arr) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }
|
||||
export function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
|
||||
|
||||
// ---------- helpers ----------
|
||||
export const F = id => G.factions[id];
|
||||
export const GEN = id => G.generals[id];
|
||||
export const CITY = id => G.cities[id];
|
||||
export const provOfLetter = letter => D.PROVINCES[letter];
|
||||
export const playerFaction = () => G.factions[G.playerFaction];
|
||||
export const isPlayerFaction = id => id === G.playerFaction;
|
||||
|
||||
export function factionGenerals(fid) { return Object.values(G.generals).filter(g => g.faction === fid && g.alive); }
|
||||
export function factionCities(fid) { return G.factions[fid].cities.map(id => G.cities[id]); }
|
||||
export function factionArmies(fid) { return Object.values(G.armies).filter(a => a.faction === fid); }
|
||||
|
||||
export function warKey(a, b) { return [a, b].sort().join("|"); }
|
||||
export const atWar = (a, b) => G.wars.includes(warKey(a, b));
|
||||
export const allied = (a, b) => G.alliances.includes(warKey(a, b));
|
||||
export const hasNap = (a, b) => G.naps.includes(warKey(a, b));
|
||||
export const tradesWith = (a, b) => G.trade.includes(warKey(a, b));
|
||||
|
||||
export function declareWar(a, b, reason) {
|
||||
const k = warKey(a, b);
|
||||
if (!G.wars.includes(k)) {
|
||||
G.wars.push(k);
|
||||
G.alliances = G.alliances.filter(x => x !== k);
|
||||
G.naps = G.naps.filter(x => x !== k);
|
||||
G.trade = G.trade.filter(x => x !== k);
|
||||
trustDrop(a, b, -40);
|
||||
F(a).fame += 2; F(b).fear += 4;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export function makePeace(a, b) {
|
||||
const k = warKey(a, b);
|
||||
if (G.wars.includes(k)) {
|
||||
G.wars = G.wars.filter(x => x !== k);
|
||||
trustBump(a, b, 10);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export function trustBump(a, b, d) {
|
||||
const fa = F(a), fb = F(b);
|
||||
if (!fa || !fb || fa.id === fb.id) return;
|
||||
fa.trust[fb.id] = clamp((fa.trust[fb.id] ?? 0) + d, -100, 100);
|
||||
fb.trust[fa.id] = clamp((fb.trust[fa.id] ?? 0) + d, -100, 100);
|
||||
}
|
||||
export function trustDrop(a, b, d) { trustBump(a, b, d); }
|
||||
export function getTrust(a, b) { return F(a)?.trust[b] ?? 0; }
|
||||
|
||||
export function log(text, type = "info") {
|
||||
G.journal.unshift({ y: G.year, m: G.month, text, type, t: Date.now() });
|
||||
if (G.journal.length > 400) G.journal.pop();
|
||||
}
|
||||
export function chronicle(text, type = "epic") {
|
||||
G.chronicle.unshift({ y: G.year, m: G.month, text, type });
|
||||
if (G.chronicle.length > 200) G.chronicle.pop();
|
||||
}
|
||||
|
||||
export function totalTroops(troops) { return Object.values(troops || {}).reduce((s, v) => s + v, 0); }
|
||||
export function factionTroopCount(fid) {
|
||||
let n = factionCities(fid).reduce((s, c) => s + totalTroops(c.garrison), 0);
|
||||
n += factionArmies(fid).reduce((s, a) => s + totalTroops(a.troops), 0);
|
||||
return n;
|
||||
}
|
||||
|
||||
// army strength estimate (raw power points)
|
||||
export function armyPower(troops, gen, mods = {}) {
|
||||
let pow = 0;
|
||||
for (const [t, n] of Object.entries(troops || {})) {
|
||||
const ut = D.UNIT_TYPES[t]; if (!ut) continue;
|
||||
pow += (n / 100) * (ut.atk + ut.def) * 0.5;
|
||||
}
|
||||
if (gen) {
|
||||
pow *= 1 + (gen.st.ldr + gen.st.war) / 400;
|
||||
if (gen.traits.includes("brave")) pow *= 1.06;
|
||||
}
|
||||
return pow * (mods.mult || 1);
|
||||
}
|
||||
|
||||
// ---------- NEW GAME ----------
|
||||
export function newGame(opts) {
|
||||
const seed = opts.seed ?? (Math.random() * 1e9) | 0;
|
||||
const state = {
|
||||
version: 3,
|
||||
seed,
|
||||
rngState: seed,
|
||||
rand: null,
|
||||
year: D.START_YEAR,
|
||||
month: D.START_MONTH,
|
||||
turnCount: 0,
|
||||
mode: opts.mode || "campaign",
|
||||
challengeDaysLeft: opts.mode === "challenge" ? 8 : Infinity,
|
||||
difficulty: opts.difficulty || "normal",
|
||||
playerFaction: null,
|
||||
factions: {}, generals: {}, cities: {}, armies: {},
|
||||
wars: [], alliances: [], naps: [], trade: [],
|
||||
prisoners: [],
|
||||
journal: [], chronicle: [],
|
||||
pendingEvents: [],
|
||||
flags: {},
|
||||
stats: { battlesWon: 0, battlesLost: 0, citiesTaken: 0, citiesLost: 0, recruited: 0, lostGenerals: 0, executed: 0, warsDeclared: 0, betrayals: 0, discoveries: 0 },
|
||||
gameOver: false, victory: null,
|
||||
replays: [],
|
||||
};
|
||||
G = state;
|
||||
G.rand = mulberry32(seed);
|
||||
|
||||
// --- provinces index ---
|
||||
G.provinces = {};
|
||||
for (const letter of Object.keys(D.PROVINCES)) {
|
||||
G.provinces[letter] = { letter, cities: [], name: D.PROVINCES[letter].name };
|
||||
}
|
||||
|
||||
// --- cities ---
|
||||
for (const [id, name, cn, prov, col, row, pop, com, fer] of D.CITY_DEFS) {
|
||||
const [wx, wz] = D.hexToWorld(col, row);
|
||||
G.cities[id] = {
|
||||
id, name, cn, prov, col, row, x: wx, z: wz,
|
||||
owner: null, pop, order: 55 + Math.floor(G.rand() * 15),
|
||||
dev: 20 + Math.floor(G.rand() * 25), commerceBase: com, fertility: fer,
|
||||
buildings: { farm: 1, market: 1, barracks: 0, wall: 0, academy: 0, workshop: 0, granary: 0 },
|
||||
garrison: { spear: pop * 4, bow: pop * 2 },
|
||||
levies: Math.floor(pop * 1.5),
|
||||
food: pop * 20, capitalOf: null, unrest: 0, siegedBy: null,
|
||||
};
|
||||
G.provinces[prov].cities.push(id);
|
||||
}
|
||||
|
||||
// --- factions ---
|
||||
for (const fdef of D.FACTION_DEFS) {
|
||||
G.factions[fdef.id] = {
|
||||
id: fdef.id, name: fdef.name, color: fdef.color, personality: fdef.personality,
|
||||
leader: null, heir: null, isPlayer: false, alive: true,
|
||||
gold: fdef.id === "neutral" ? 1200 : 2400 + Math.floor(G.rand() * 1400),
|
||||
food: 6000 + Math.floor(G.rand() * 4000),
|
||||
legitimacy: 50, fame: 10, fear: 5, honor: 50, corruption: 10,
|
||||
rank: "Governor", cities: [], trust: {}, memory: [], capital: null,
|
||||
taxRate: 0.55, lastIncome: 0, lastFood: 0,
|
||||
};
|
||||
for (const cid of fdef.startCities) {
|
||||
assignCity(cid, fdef.id, true);
|
||||
}
|
||||
}
|
||||
// neutral leader-less
|
||||
G.factions.neutral.leader = null;
|
||||
G.factions.huangjin.leader = "guanhai";
|
||||
|
||||
// --- generals ---
|
||||
for (const gd of D.GENERAL_DEFS) {
|
||||
const fid = gd.servingDong ? "dong" : gd.f;
|
||||
const g = makeGeneral(gd.n, fid, gd.b, gd.st, gd.tr, gd.sk, gd.t);
|
||||
if (gd.hidden) g.hidden = true;
|
||||
if (gd.freeAgent) g.freeAgent = true;
|
||||
if (gd.heirOf) G.factions[gd.heirOf].heir = g.id;
|
||||
const fdef = D.FACTION_DEFS.find(f => f.id === fid);
|
||||
if (!gd.hidden && fdef?.leader === gd.n.toLowerCase().replace(/[\s']/g, "")) {
|
||||
const fac = G.factions[fid];
|
||||
if (fac) { fac.leader = g.id; g.isLeader = true; }
|
||||
}
|
||||
}
|
||||
// Lü Bu leads his own faction
|
||||
const lb = findGen("Lü Bu");
|
||||
lb.faction = "lubu"; lb.isLeader = true;
|
||||
G.factions.lubu.leader = lb.id;
|
||||
|
||||
// place generals in their faction's cities (leader in capital)
|
||||
for (const fac of Object.values(G.factions)) {
|
||||
if (!fac.cities.length) continue;
|
||||
fac.capital = fac.cities[0];
|
||||
G.cities[fac.capital].capitalOf = fac.id;
|
||||
const gens = factionGenerals(fac.id).filter(g => !g.hidden && !g.freeAgent);
|
||||
gens.forEach((g, i) => { g.location = fac.cities[i % fac.cities.length]; });
|
||||
if (fac.leader) GEN(fac.leader).location = fac.capital;
|
||||
}
|
||||
// hidden talents wait in specific towns; free agents roam
|
||||
if (G.generals.huangzhong) G.generals.huangzhong.location = "changsha";
|
||||
if (G.generals.weiyan) G.generals.weiyan.location = "changsha";
|
||||
if (G.generals.ganning) G.generals.ganning.location = "jiangxia";
|
||||
if (G.generals.taishici) { G.generals.taishici.location = null; G.generals.taishici.roams = true; }
|
||||
// relationships
|
||||
applyRelations();
|
||||
|
||||
// --- player setup ---
|
||||
setupPlayer(opts);
|
||||
|
||||
// opening chronicle
|
||||
chronicle(`Winter ${G.year}. The Han dynasty crumbles. Warlords carve the empire.`, "epic");
|
||||
log("The campaign begins. History is unwritten.", "epic");
|
||||
|
||||
return G;
|
||||
}
|
||||
|
||||
export function makeGeneral(name, faction, birth, st, traits, skillId, title) {
|
||||
let id = name.toLowerCase().replace(/[^a-z]/g, "") || uid("gen");
|
||||
if (G.generals[id]) id = uid("gen");
|
||||
const g = {
|
||||
id, name, title: title || "", faction, birth,
|
||||
st: { ldr: st[0], war: st[1], int: st[2], pol: st[3], cha: st[4] },
|
||||
traits: traits || [], skill: skillId || "rally",
|
||||
age: G.year - birth,
|
||||
loyalty: 60 + Math.floor(G.rand() * 30), morale: 70,
|
||||
xp: 0, level: 1, wounded: 0, alive: true, hidden: false, freeAgent: false,
|
||||
location: null, ambition: 30 + Math.floor(G.rand() * 40),
|
||||
salary: 0, kills: 0, isLeader: false, renown: 0, bondPlayer: 0,
|
||||
};
|
||||
g.salary = Math.round((st[0] + st[1] + st[2]) / 3) * 2;
|
||||
G.generals[id] = g;
|
||||
return g;
|
||||
}
|
||||
|
||||
function applyRelations() {
|
||||
for (const [a, b] of D.RELATIONS.sworn) {
|
||||
const ga = findGen(a), gb = findGen(b);
|
||||
if (ga && gb) { ga.sworn = ga.sworn || []; ga.sworn.push(gb.id); gb.sworn = gb.sworn || []; gb.sworn.push(ga.id); ga.loyalty = clamp(ga.loyalty + 10, 0, 100); gb.loyalty = clamp(gb.loyalty + 10, 0, 100); }
|
||||
}
|
||||
for (const [a, b] of D.RELATIONS.rivals) {
|
||||
const ga = findGen(a), gb = findGen(b);
|
||||
if (ga && gb) { ga.rivals = ga.rivals || []; ga.rivals.push(gb.id); gb.rivals = gb.rivals || []; gb.rivals.push(ga.id); }
|
||||
}
|
||||
for (const [a, b] of D.RELATIONS.friends) {
|
||||
const ga = findGen(a), gb = findGen(b);
|
||||
if (ga && gb) { ga.friends = ga.friends || []; ga.friends.push(gb.id); gb.friends = gb.friends || []; gb.friends.push(ga.id); }
|
||||
}
|
||||
for (const [a, b] of D.RELATIONS.family) {
|
||||
const ga = findGen(a), gb = findGen(b);
|
||||
if (ga && gb) { ga.family = ga.family || []; ga.family.push(gb.id); gb.family = gb.family || []; gb.family.push(ga.id); }
|
||||
}
|
||||
}
|
||||
export function findGen(name) {
|
||||
return Object.values(G.generals).find(g => g.name === name);
|
||||
}
|
||||
|
||||
export function assignCity(cityId, factionId, initial = false) {
|
||||
const c = G.cities[cityId];
|
||||
const oldFac = c.owner;
|
||||
if (oldFac && G.factions[oldFac]) {
|
||||
G.factions[oldFac].cities = G.factions[oldFac].cities.filter(x => x !== cityId);
|
||||
}
|
||||
c.owner = factionId;
|
||||
if (factionId && G.factions[factionId]) {
|
||||
if (!G.factions[factionId].cities.includes(cityId)) G.factions[factionId].cities.push(cityId);
|
||||
if (!initial) {
|
||||
c.unrest = 30; c.order = Math.max(c.order - 25, 15);
|
||||
G.stats.citiesTaken += isPlayerFaction(factionId) ? 1 : 0;
|
||||
G.stats.citiesLost += oldFac && isPlayerFaction(oldFac) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function generateName() {
|
||||
const s = pick(D.SURNAME);
|
||||
const surname = s.endsWith("2") ? s.slice(0, -1) : s;
|
||||
return `${surname} ${pick(D.GIVEN)}`;
|
||||
}
|
||||
|
||||
export function makeGenericGeneral(faction, minPower = 50) {
|
||||
const name = generateName();
|
||||
const q = minPower + Math.floor(rand() * 30);
|
||||
const g = makeGeneral(name, faction, G.year - randInt(22, 45),
|
||||
[q + randInt(-8, 12), q + randInt(-8, 12), q + randInt(-15, 15), q + randInt(-18, 10), q + randInt(-15, 15)],
|
||||
[pick(Object.keys(D.TRAITS))], pick(["chargecall", "volley", "rally", "ambush"]), "");
|
||||
g.generic = true;
|
||||
g.loyalty = 45 + randInt(0, 25);
|
||||
return g;
|
||||
}
|
||||
|
||||
// ---------- PLAYER ----------
|
||||
function setupPlayer(opts) {
|
||||
if (opts.playerType === "faction") {
|
||||
G.playerFaction = opts.factionId;
|
||||
F(opts.factionId).isPlayer = true;
|
||||
return;
|
||||
}
|
||||
// custom warlord spawns replacing a neutral spawn city
|
||||
const cityId = opts.custom.cityId;
|
||||
const city = G.cities[cityId];
|
||||
const fid = "player";
|
||||
const fac = {
|
||||
id: fid, name: opts.custom.factionName || `House of ${opts.custom.rulerName}`, color: opts.custom.color,
|
||||
personality: "hawk", leader: null, heir: null, isPlayer: true, alive: true,
|
||||
gold: 2800, food: 7000, legitimacy: 35 + (D.ORIGINS[opts.custom.origin].bonus.legit || 0),
|
||||
fame: 5 + (D.ORIGINS[opts.custom.origin].bonus.fame || 0),
|
||||
fear: (D.ORIGINS[opts.custom.origin].bonus.fear || 0),
|
||||
honor: 50 + (D.ORIGINS[opts.custom.origin].bonus.honor || 0),
|
||||
corruption: 8, rank: "Governor", cities: [], trust: {}, memory: [], capital: cityId,
|
||||
taxRate: 0.55, lastIncome: 0, lastFood: 0, emblem: opts.custom.emblem, origin: opts.custom.origin,
|
||||
};
|
||||
G.factions[fid] = fac;
|
||||
G.playerFaction = fid;
|
||||
assignCity(cityId, fid, true);
|
||||
city.order = 65; city.unrest = 0;
|
||||
|
||||
// ruler general
|
||||
const ob = D.ORIGINS[opts.custom.origin].bonus;
|
||||
const base = { governor: [62, 46, 66, 82, 74], soldier: [72, 80, 48, 42, 58], noble: [64, 52, 58, 68, 78], bandit: [70, 84, 40, 30, 52], merchant: [54, 40, 66, 80, 84], scholar: [56, 36, 84, 76, 72], exile: [80, 86, 62, 50, 66], rebel: [74, 72, 56, 44, 80] }[opts.custom.origin];
|
||||
const ruler = makeGeneral(opts.custom.rulerName, fid, G.year - randInt(24, 34), base, ["charismatic"], "rally", "Ruler");
|
||||
ruler.isLeader = true; ruler.loyalty = 100; ruler.location = cityId;
|
||||
fac.leader = ruler.id;
|
||||
|
||||
// companions
|
||||
const comp1 = makeGenericGeneral(fid, 58); comp1.location = cityId; comp1.loyalty = 85;
|
||||
const comp2 = makeGenericGeneral(fid, 52); comp2.location = cityId; comp2.loyalty = 80;
|
||||
if (ob.companion) {
|
||||
const vet = findGen("Taishi Ci") || findGen("Huang Zhong");
|
||||
if (vet && !vet.faction || (vet && (vet.freeAgent || vet.hidden))) {
|
||||
vet.faction = fid; vet.hidden = false; vet.freeAgent = false;
|
||||
vet.location = cityId; vet.loyalty = 95; vet.sworn = [ruler.id];
|
||||
vet.title = "Sworn Shield";
|
||||
} else {
|
||||
const v = makeGenericGeneral(fid, 70);
|
||||
v.location = cityId; v.loyalty = 95; v.title = "Sworn Shield"; v.sworn = [ruler.id];
|
||||
}
|
||||
}
|
||||
// starting forces
|
||||
city.garrison = { spear: 1400, bow: 700, sword: 400 };
|
||||
if (ob.extraTroops) city.garrison.spear += ob.extraTroops * 3;
|
||||
}
|
||||
|
||||
// ---------- SAVE / LOAD ----------
|
||||
const SAVE_PREFIX = "warlords-fate-save-";
|
||||
function store() { try { return globalThis.localStorage; } catch { return null; } }
|
||||
export function saveGame(slot = "autosave") {
|
||||
const data = JSON.stringify(G, (k, v) => (v instanceof Set ? [...v] : v));
|
||||
try {
|
||||
store()?.setItem(SAVE_PREFIX + slot, JSON.stringify({ when: Date.now(), label: `${F(G.playerFaction)?.name ?? "?"} — Winter ${G.year}/${String(G.month).padStart(2, "0")} — ${slot}`, data }));
|
||||
return true;
|
||||
} catch (e) { console.warn("save failed", e); return false; }
|
||||
}
|
||||
export function listSaves() {
|
||||
const out = [];
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k?.startsWith(SAVE_PREFIX)) {
|
||||
try { const rec = JSON.parse(store()?.getItem(k)); out.push({ slot: k.slice(SAVE_PREFIX.length), when: rec.when, label: rec.label }); } catch { }
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
return out.sort((a, b) => b.when - a.when);
|
||||
}
|
||||
export function loadGame(slot) {
|
||||
try {
|
||||
const raw = store()?.getItem(SAVE_PREFIX + slot);
|
||||
if (!raw) return null;
|
||||
const rec = JSON.parse(raw);
|
||||
G = JSON.parse(rec.data);
|
||||
G.rand = mulberry32(G.seed + G.turnCount * 7919);
|
||||
return G;
|
||||
} catch (e) { console.warn("load failed", e); return null; }
|
||||
}
|
||||
export function hasAutosave() {
|
||||
try { return !!store()?.getItem(SAVE_PREFIX + "autosave"); } catch { return false; }
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
// ============================================================
|
||||
// UI — panels, HUD, modals, toasts
|
||||
// ============================================================
|
||||
import * as D from "./data.js";
|
||||
import { G, F, GEN, CITY, playerFaction, isPlayerFaction } from "./state.js";
|
||||
|
||||
const $ = sel => document.querySelector(sel);
|
||||
const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; };
|
||||
const fmt = n => n >= 10000 ? (n / 1000).toFixed(1) + "k" : Math.round(n).toLocaleString();
|
||||
const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
|
||||
export class UI {
|
||||
constructor(hooks) {
|
||||
this.hooks = hooks;
|
||||
this.activeTab = null;
|
||||
this.selected = null;
|
||||
this.bindStatic();
|
||||
}
|
||||
|
||||
bindStatic() {
|
||||
$("#btn-panel-close").onclick = () => this.closePanel();
|
||||
document.querySelectorAll(".tab-btn").forEach(b => {
|
||||
b.onclick = () => {
|
||||
const tab = b.dataset.tab;
|
||||
if (this.activeTab === tab) this.closePanel();
|
||||
else this.openTab(tab);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- HUD ----------------
|
||||
refreshHUD() {
|
||||
const pf = playerFaction();
|
||||
if (!pf) return;
|
||||
const season = D.seasonOfMonth(G.month);
|
||||
$("#hud-season").textContent = season;
|
||||
$("#hud-date").textContent = `${D.seasonOfMonth(G.month)} ${G.year} · ${["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][G.month - 1]}`;
|
||||
$("#hud-gold").textContent = fmt(pf.gold);
|
||||
this.setDelta("#hud-gold-d", pf.lastIncome);
|
||||
$("#hud-food").textContent = fmt(pf.food);
|
||||
this.setDelta("#hud-food-d", pf.lastFood);
|
||||
let troops = 0;
|
||||
for (const cid of pf.cities) troops += Object.values(CITY(cid).garrison).reduce((a, b) => a + b, 0);
|
||||
for (const a of Object.values(G.armies)) if (a.faction === pf.id) troops += Object.values(a.troops).reduce((x, y) => x + y, 0);
|
||||
$("#hud-troops").textContent = fmt(troops);
|
||||
$("#hud-cities").textContent = pf.cities.length;
|
||||
$("#hud-generals").textContent = Object.values(G.generals).filter(g => g.alive && g.faction === pf.id).length;
|
||||
$("#hud-legit").textContent = Math.round(pf.legitimacy);
|
||||
|
||||
// objective note
|
||||
const obj = [];
|
||||
if (G.mode === "challenge") obj.push(`Survive the trial — ${Math.max(0, G.challengeDaysLeft)} turns remain.`);
|
||||
else {
|
||||
obj.push(`Victory: hold <b>20 cities</b> (now ${pf.cities.length}), or unite China, or be crowned Emperor.`);
|
||||
if (!pf.cities.includes("changan") || !pf.cities.includes("luoyang")) obj.push("Hold Chang'an & Luoyang with high legitimacy to claim the Mandate.");
|
||||
}
|
||||
$("#turn-objective").innerHTML = obj.join("<br>");
|
||||
}
|
||||
|
||||
setDelta(sel, v) {
|
||||
const e = $(sel);
|
||||
if (v > 0) { e.textContent = `+${fmt(v)}`; e.className = "delta"; }
|
||||
else if (v < 0) { e.textContent = `−${fmt(-v)}`; e.className = "delta neg"; }
|
||||
else { e.textContent = ""; e.className = "delta"; }
|
||||
}
|
||||
|
||||
// ---------------- panel routing ----------------
|
||||
openTab(tab) {
|
||||
this.activeTab = tab;
|
||||
document.querySelectorAll(".tab-btn").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
|
||||
$("#side-panel").classList.remove("hidden");
|
||||
const content = $("#panel-content");
|
||||
content.innerHTML = "";
|
||||
if (tab === "court") this.renderGenerals(content);
|
||||
else if (tab === "cities") this.renderCities(content);
|
||||
else if (tab === "armies") this.renderArmies(content);
|
||||
else if (tab === "diplomacy") this.renderDiplomacy(content);
|
||||
else if (tab === "court-politics") this.renderCourt(content);
|
||||
else if (tab === "journal") this.renderJournal(content);
|
||||
else if (tab === "chronicle") this.renderChronicle(content);
|
||||
else if (tab === "help") this.renderHelp(content);
|
||||
}
|
||||
|
||||
closePanel() {
|
||||
this.activeTab = null;
|
||||
$("#side-panel").classList.add("hidden");
|
||||
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
||||
}
|
||||
|
||||
rerender() {
|
||||
this.refreshHUD();
|
||||
if (this.activeTab) this.openTab(this.activeTab);
|
||||
}
|
||||
|
||||
// ---------------- PORTRAITS ----------------
|
||||
portrait(g, size = 52) {
|
||||
const fac = F(g.faction);
|
||||
const col = fac?.color || "#888";
|
||||
const initial = g.name[0].toUpperCase();
|
||||
return `<div class="gen-portrait" style="width:${size}px;height:${size}px;font-size:${size * 0.44}px;background:
|
||||
radial-gradient(circle at 35% 30%, ${col}cc, ${col}55 60%, #00000088);border-color:${col}">${initial}</div>`;
|
||||
}
|
||||
|
||||
statChips(g) {
|
||||
return `<div class="stat-row">
|
||||
<span class="stat-chip">LDR <b>${g.st.ldr}</b></span><span class="stat-chip">WAR <b>${g.st.war}</b></span>
|
||||
<span class="stat-chip">INT <b>${g.st.int}</b></span><span class="stat-chip">POL <b>${g.st.pol}</b></span>
|
||||
<span class="stat-chip">CHA <b>${g.st.cha}</b></span></div>`;
|
||||
}
|
||||
|
||||
loyaltyColor(l) { return l > 70 ? "#7ab55f" : l > 45 ? "#c9a53c" : "#c05540"; }
|
||||
|
||||
// ---------------- GENERALS PANEL ----------------
|
||||
renderGenerals(root) {
|
||||
const pf = playerFaction();
|
||||
root.appendChild(el("div", "panel-h", `Your Officers · ${pf.name}`));
|
||||
const mine = Object.values(G.generals).filter(g => g.alive && g.faction === pf.id)
|
||||
.sort((a, b) => (b.isLeader ? 1 : 0) - (a.isLeader ? 1 : 0) || b.st.ldr + b.st.war - (a.st.ldr + a.st.war));
|
||||
for (const g of mine) root.appendChild(this.genCard(g));
|
||||
// free agents known
|
||||
const agents = Object.values(G.generals).filter(g => g.alive && g.freeAgent && !g.hidden);
|
||||
if (agents.length) {
|
||||
root.appendChild(el("div", "sub-h", "Wandering Heroes (recruit via court events or espionage)"));
|
||||
for (const g of agents.slice(0, 6)) root.appendChild(this.genCard(g, true));
|
||||
}
|
||||
}
|
||||
|
||||
genCard(g, dim = false) {
|
||||
const card = el("div", "gen-card");
|
||||
if (dim) card.style.opacity = 0.75;
|
||||
const loc = typeof g.location === "string" ? (CITY(g.location)?.name ?? (g.location.startsWith("army") ? "with army" : "wandering")) : "on campaign";
|
||||
card.innerHTML = `
|
||||
${this.portrait(g)}
|
||||
<div class="gen-info">
|
||||
<div class="gen-name">${esc(g.name)}${g.isLeader ? ' 👑' : ''}<span class="age">age ${g.age}</span></div>
|
||||
<div class="gen-title">${esc(g.title || "Officer")} · at ${esc(loc)}</div>
|
||||
${this.statChips(g)}
|
||||
<div style="margin-top:5px;display:flex;gap:4px;flex-wrap:wrap">
|
||||
${g.traits.map(t => `<span class="trait-tag">${D.TRAITS[t]?.icon ?? ""} ${D.TRAITS[t]?.name ?? t}</span>`).join("")}
|
||||
<span class="trait-tag" title="${esc(D.SKILLS[g.skill]?.name ?? "")}">★ ${esc(D.SKILLS[g.skill]?.name ?? "Rally")}</span>
|
||||
</div>
|
||||
<div class="bar-wrap"><span>Loyalty</span><div class="bar-track"><div class="bar-fill" style="width:${g.loyalty}%;background:${this.loyaltyColor(g.loyalty)}"></div></div><span>${Math.round(g.loyalty)}</span></div>
|
||||
</div>
|
||||
<div class="gen-flags">
|
||||
${g.wounded > 0 ? '<span class="flag-wounded" title="Wounded">✚</span>' : ""}
|
||||
${Object.values(G.armies).some(a => a.genId === g.id) ? '<span class="flag-battle" title="Commanding an army">🐴</span>' : ""}
|
||||
${g.freeAgent ? '<span class="flag-hidden" title="Free agent">🕊</span>' : ""}
|
||||
</div>`;
|
||||
if (!dim) {
|
||||
card.onclick = () => this.showGeneralDetail(g);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
showGeneralDetail(g) {
|
||||
const pf = playerFaction();
|
||||
const mine = g.faction === pf.id;
|
||||
const armyHere = Object.values(G.armies).find(a => a.genId === g.id);
|
||||
const locCity = typeof g.location === "string" ? CITY(g.location) : null;
|
||||
const box = el("div");
|
||||
box.appendChild(el("div", "panel-h", esc(g.name)));
|
||||
box.insertAdjacentHTML("beforeend", `
|
||||
<div style="display:flex;gap:12px;align-items:center;margin-bottom:10px">${this.portrait(g, 64)}
|
||||
<div><div class="gen-title" style="font-size:13px">${esc(g.title || "Officer")} of ${esc(F(g.faction)?.name ?? "?")}</div>
|
||||
<div style="color:#b3a17c;font-size:12px">Age ${g.age} · Renown ${Math.round(g.renown)} · Salary ${g.salary}/quarter</div></div></div>
|
||||
${this.statChips(g)}
|
||||
<div style="margin-top:8px">
|
||||
${g.traits.map(t => `<span class="trait-tag" title="${esc(D.TRAITS[t]?.desc ?? "")}">${D.TRAITS[t]?.icon ?? ""} ${D.TRAITS[t]?.name}</span>`).join(" ")}
|
||||
<span class="trait-tag" title="Heroic skill">★ ${esc(D.SKILLS[g.skill]?.name)} — <i>${esc(D.SKILLS[g.skill]?.text ?? "")}</i></span>
|
||||
</div>
|
||||
<div class="bar-wrap"><span>Loyalty</span><div class="bar-track"><div class="bar-fill" style="width:${g.loyalty}%;background:${this.loyaltyColor(g.loyalty)}"></div></div><span>${Math.round(g.loyalty)}</span></div>
|
||||
<div class="bar-wrap"><span>Morale</span><div class="bar-track"><div class="bar-fill" style="width:${g.morale}%"></div></div><span>${Math.round(g.morale)}</span></div>
|
||||
${(g.sworn || []).length ? `<div style="font-size:12px;color:#b3a17c;margin-top:6px">Sworn brothers: ${(g.sworn || []).map(id => GEN(id)?.alive ? GEN(id).name : "").filter(Boolean).join(", ")}</div>` : ""}
|
||||
${(g.rivals || []).length ? `<div style="font-size:12px;color:#b38070">Rivals: ${(g.rivals || []).map(id => GEN(id)?.name).filter(Boolean).join(", ")}</div>` : ""}
|
||||
`);
|
||||
if (mine) {
|
||||
const row = el("div", "btn-row");
|
||||
row.innerHTML = `
|
||||
<button class="btn-tiny" data-act="promote">Promote (400g)</button>
|
||||
<button class="btn-tiny" data-act="gift">Gift (300g)</button>
|
||||
${locCity && !armyHere ? `<button class="btn-tiny" data-act="search">Search for talents here</button>` : ""}
|
||||
`;
|
||||
row.querySelectorAll("button").forEach(b => {
|
||||
b.onclick = () => {
|
||||
const res = this.hooks.onCourtAction(b.dataset.act, g.id);
|
||||
this.toast(res.msg, res.ok ? "good" : "bad");
|
||||
this.refreshAll();
|
||||
if (res.discovered) this.toast(`Discovered: ${res.discovered.name}!`, "epic");
|
||||
};
|
||||
});
|
||||
box.appendChild(row);
|
||||
}
|
||||
this.swapPanel(box);
|
||||
}
|
||||
|
||||
// ---------------- CITIES PANEL ----------------
|
||||
renderCities(root) {
|
||||
const pf = playerFaction();
|
||||
root.appendChild(el("div", "panel-h", "Your Cities"));
|
||||
for (const cid of pf.cities) {
|
||||
root.appendChild(this.cityCard(CITY(cid)));
|
||||
}
|
||||
if (!pf.cities.length) root.appendChild(el("p", "", "You hold no cities."));
|
||||
}
|
||||
|
||||
cityCard(c) {
|
||||
const detail = this.hooks.onCityDetail(c.id);
|
||||
const card = el("div", "city-card");
|
||||
const fac = F(c.owner);
|
||||
card.innerHTML = `
|
||||
<div class="city-head">
|
||||
<div class="city-name"><span class="owner-dot" style="background:${fac.color}"></span>${esc(c.name)}${c.capitalOf === c.owner ? " ★" : ""}<span class="prov">${D.PROVINCES[c.prov].cn} · Tier ${detail.tier}</span></div>
|
||||
</div>
|
||||
<div class="city-stats">
|
||||
<span>Pop <b>${fmt(c.pop)}k</b></span>
|
||||
<span>Tax <b>+${detail.tax}</b></span>
|
||||
<span>Food <b>${detail.food >= 0 ? "+" : ""}${detail.food}</b></span>
|
||||
<span>Order <b>${Math.round(c.order)}</b></span>
|
||||
</div>
|
||||
<div class="bar-wrap"><span>Garrison</span><div class="bar-track"><div class="bar-fill" style="width:${Math.min(100, detail.garrison / 50)}%;background:#7a8fbf"></div></div><span>${fmt(detail.garrison)}</span></div>
|
||||
<div class="bar-wrap"><span>Levies</span><div class="bar-track"><div class="bar-fill" style="width:${Math.min(100, c.levies / (c.pop / 2.5 / 100) )}%;background:#9aa87a"></div></div><span>${fmt(c.levies)}</span></div>`;
|
||||
// buildings
|
||||
const grid = el("div", "build-grid");
|
||||
for (const [key, bd] of Object.entries(D.BUILDINGS)) {
|
||||
const lv = c.buildings[key];
|
||||
const maxed = lv >= bd.maxLv;
|
||||
const slot = el("div", `build-slot ${maxed ? "max" : "up"}`);
|
||||
slot.innerHTML = `${bd.icon} <span class="lv">${maxed ? "L.v " + lv : lv >= 0 ? lv + "→" + (lv + 1) : "build"}</span><br><small>${bd.name}</small>`;
|
||||
slot.title = maxed ? `${bd.name} at maximum` : `Upgrade ${bd.name}: ${bd.cost(lv)} gold — ${bd.desc}`;
|
||||
slot.onclick = () => {
|
||||
const res = this.hooks.onBuild(c.id, key);
|
||||
this.toast(res.msg, res.ok ? "good" : "bad");
|
||||
this.refreshAll();
|
||||
};
|
||||
grid.appendChild(slot);
|
||||
}
|
||||
card.appendChild(grid);
|
||||
// recruitment
|
||||
const recRow = el("div", "btn-row");
|
||||
for (const [key, ut] of Object.entries(D.UNIT_TYPES)) {
|
||||
const btn = el("button", "btn-tiny", `${ut.icon} +200 ${ut.name.split(" ")[0]}`);
|
||||
btn.title = `${ut.desc} — cost ~${ut.cost * 2} gold`;
|
||||
btn.onclick = () => {
|
||||
const res = this.hooks.onRecruit(c.id, key, 200);
|
||||
this.toast(res.msg || res.why, res.ok ? "good" : "bad");
|
||||
this.refreshAll();
|
||||
};
|
||||
recRow.appendChild(btn);
|
||||
}
|
||||
card.appendChild(recRow);
|
||||
// raise army
|
||||
const idleGens = Object.values(G.generals).filter(g => g.alive && g.faction === c.owner && !g.hidden &&
|
||||
g.location === c.id && !Object.values(G.armies).some(a => a.genId === g.id));
|
||||
if (idleGens.length && detail.garrison >= 500) {
|
||||
const form = el("div", "");
|
||||
form.style.marginTop = "8px";
|
||||
const sel = el("select");
|
||||
sel.style.cssText = "background:#120c06;border:1px solid #33271a;color:var(--parch);padding:5px;border-radius:3px;width:170px";
|
||||
for (const g of idleGens) sel.appendChild(new Option(`${g.name} (LDR ${g.st.ldr})`, g.id));
|
||||
const halfBtn = el("button", "btn-tiny warn", `⚑ March out with ${idleGens[0].name}`);
|
||||
const updateLabel = () => {
|
||||
const g = GEN(sel.value);
|
||||
halfBtn.textContent = `⚑ March out with ${g.name}`;
|
||||
};
|
||||
sel.onchange = updateLabel;
|
||||
halfBtn.onclick = () => {
|
||||
// draft ~60% of garrison balanced
|
||||
const troops = {};
|
||||
for (const [t, n] of Object.entries(c.garrison)) {
|
||||
troops[t] = Math.floor(n * (t === "hcav" || t === "cav" ? 0.85 : 0.62));
|
||||
}
|
||||
const res = this.hooks.onRaiseArmy(c.id, sel.value, troops);
|
||||
this.toast(res.msg || res.why, res.ok ? "good" : "bad");
|
||||
this.refreshAll();
|
||||
};
|
||||
form.appendChild(sel);
|
||||
form.appendChild(halfBtn);
|
||||
card.appendChild(form);
|
||||
}
|
||||
card.style.cursor = "pointer";
|
||||
card.querySelector(".city-head").onclick = () => { this.hooks.onFocusCity(c.id); };
|
||||
return card;
|
||||
}
|
||||
|
||||
showCityPanel(cityId) {
|
||||
const c = CITY(cityId);
|
||||
if (!c) return;
|
||||
if (c.owner === G.playerFaction) { this.openTab("cities"); }
|
||||
else this.showForeignCity(c);
|
||||
}
|
||||
|
||||
showForeignCity(c) {
|
||||
const fac = F(c.owner);
|
||||
const detail = this.hooks.onCityDetail(c.id);
|
||||
const box = el("div");
|
||||
box.appendChild(el("div", "panel-h", `${esc(c.name)} — ${esc(fac?.name ?? "Wasteland")}`));
|
||||
box.insertAdjacentHTML("beforeend", `
|
||||
<p style="color:#cdbb93;line-height:1.7;font-size:13px">${esc(D.PROVINCES[c.prov].desc)}</p>
|
||||
<div class="city-stats">
|
||||
<span>Owner <b style="color:${fac.color}">${esc(fac?.name ?? "-")}</b></span>
|
||||
<span>Pop <b>${fmt(c.pop)}k</b></span>
|
||||
<span>Garrison <b>${fmt(detail.garrison)}</b></span>
|
||||
<span>Walls <b>${c.buildings.wall}</b></span>
|
||||
</div>`);
|
||||
const gens = Object.values(G.generals).filter(g => g.alive && g.faction === c.owner && g.location === c.id);
|
||||
if (gens.length) {
|
||||
box.appendChild(el("div", "sub-h", "Present officers"));
|
||||
for (const g of gens.slice(0, 4)) box.insertAdjacentHTML("beforeend", `<div style="font-size:12.5px;color:#d6c49b">· ${esc(g.name)} <span style="color:#97835b">(WAR ${g.st.war})</span></div>`);
|
||||
}
|
||||
this.swapPanel(box);
|
||||
}
|
||||
|
||||
// ---------------- ARMIES ----------------
|
||||
renderArmies(root) {
|
||||
const pf = playerFaction();
|
||||
root.appendChild(el("div", "panel-h", "Armies on Campaign"));
|
||||
const mine = Object.values(G.armies).filter(a => a.faction === pf.id);
|
||||
if (!mine.length) root.appendChild(el("p", "", `<span style="color:#97835b">No armies in the field. Raise one from a city.</span>`));
|
||||
for (const a of mine) root.appendChild(this.armyCard(a));
|
||||
// enemy armies visible summary
|
||||
root.appendChild(el("div", "sub-h", "Foreign banners sighted"));
|
||||
const foreign = Object.values(G.armies).filter(a => a.faction !== pf.id);
|
||||
for (const a of foreign.slice(0, 10)) {
|
||||
const total = Object.values(a.troops).reduce((s, v) => s + v, 0);
|
||||
root.insertAdjacentHTML("beforeend", `<div class="journal-entry war">${GEN(a.genId)?.name ?? "?"} (${F(a.faction).name}) — ${fmt(total)} men at ${D.PROVINCES[a.prov].name}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
armyCard(a) {
|
||||
const card = el("div", "city-card");
|
||||
const total = Object.values(a.troops).reduce((s, v) => s + v, 0);
|
||||
const gen = GEN(a.genId);
|
||||
card.innerHTML = `
|
||||
<div class="city-head"><div class="city-name">${esc(gen?.name ?? "?" )}'s Host<span class="prov">${D.PROVINCES[a.prov].name}${a.moved ? " · marched" : ""}</span></div>
|
||||
<b style="color:#e8c66a">${fmt(total)}</b></div>
|
||||
<div class="bar-wrap"><span>Morale</span><div class="bar-track"><div class="bar-fill" style="width:${a.morale}%;background:${this.loyaltyColor(a.morale)}"></div></div><span>${Math.round(a.morale)}</span></div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">
|
||||
${Object.entries(a.troops).filter(([, n]) => n > 0).map(([t, n]) => `<span class="stat-chip">${D.UNIT_TYPES[t].icon} ${fmt(n)}</span>`).join("")}
|
||||
</div>`;
|
||||
const row = el("div", "btn-row");
|
||||
const sameProv = Object.values(G.armies).filter(x => x.faction === a.faction && x.prov === a.prov && x.id !== a.id);
|
||||
if (sameProv.length) {
|
||||
const mb = el("button", "btn-tiny", "⇊ Merge armies here");
|
||||
mb.onclick = () => { this.hooks.onMerge([a.id, ...sameProv.map(x => x.id)]); this.refreshAll(); };
|
||||
row.appendChild(mb);
|
||||
}
|
||||
const db = el("button", "btn-tiny warn", "✕ Disband");
|
||||
db.title = "Return troops to nearest friendly city";
|
||||
db.onclick = () => { this.hooks.onDisband(a.id); this.refreshAll(); };
|
||||
row.appendChild(db);
|
||||
card.appendChild(row);
|
||||
card.querySelector(".city-head").style.cursor = "pointer";
|
||||
card.querySelector(".city-head").onclick = () => { this.hooks.onSelectArmy(a.id); };
|
||||
return card;
|
||||
}
|
||||
|
||||
// ---------------- DIPLOMACY ----------------
|
||||
renderDiplomacy(root) {
|
||||
const pf = playerFaction();
|
||||
root.appendChild(el("div", "panel-h", "Diplomacy of the Realm"));
|
||||
root.insertAdjacentHTML("beforeend", `<p style="font-size:12px;color:#97835b;margin-bottom:10px">Trust is remembered. Gifts build it; betrayal poisons every court in China.</p>`);
|
||||
for (const f of Object.values(G.factions)) {
|
||||
if (!f.alive || f.id === pf.id || f.id === "neutral") continue;
|
||||
root.appendChild(this.diploRow(pf, f));
|
||||
}
|
||||
}
|
||||
|
||||
diploRow(pf, f) {
|
||||
const trust = f.trust[pf.id] ?? 0;
|
||||
const war = G.wars.includes([pf.id, f.id].sort().join("|"));
|
||||
const ally = G.alliances.includes([pf.id, f.id].sort().join("|"));
|
||||
const nap = G.naps.includes([pf.id, f.id].sort().join("|"));
|
||||
const trade = G.trade.includes([pf.id, f.id].sort().join("|"));
|
||||
const rel = war ? '<span class="rel-war">⚔ WAR</span>' : ally ? '<span class="rel-alliance">🤝 ALLIANCE</span>' :
|
||||
nap ? '<span class="rel-nap">📜 NAP</span>' : trade ? '<span class="rel-trade">⚖ TRADE</span>' : '<span class="rel-peace">PEACE</span>';
|
||||
const card = el("div", "diplo-row");
|
||||
card.innerHTML = `
|
||||
<div class="diplo-flag" style="background:${f.color};color:#111">${(f.emblem || f.name[0])}</div>
|
||||
<div class="diplo-name"><b>${esc(f.name)}</b> ${f.rank !== "Governor" ? `<span style="color:#d8b25f;font-size:11px">(${f.rank})</span>` : ""}
|
||||
<div class="diplo-rel">${rel} · cities ${f.cities.length} · legit ${Math.round(f.legitimacy)}</div></div>
|
||||
<div class="trust-meter" title="Trust"><div style="position:absolute;left:${trust >= 0 ? 50 : 50 + trust / 2}%;top:0;bottom:0;width:${Math.abs(trust) / 2}%;background:${trust >= 0 ? "#7ab55f" : "#c05540"}"></div></div>`;
|
||||
const acts = el("div", "btn-row");
|
||||
acts.style.cssText = "flex-basis:100%;justify-content:flex-start";
|
||||
const mk = (label, act, cls = "") => {
|
||||
const b = el("button", "btn-tiny " + cls, label);
|
||||
b.onclick = () => {
|
||||
const res = this.hooks.onDiplo(act, f.id);
|
||||
this.toast(res.msg, res.ok ? "good" : res.spyFail ? "bad" : "info");
|
||||
if (res.spy && res.unhappyGen) {
|
||||
this.toast(`Disgruntled officer: ${res.unhappyGen.name} — you may attempt a bribe.`, "epic");
|
||||
G.flags.spyTarget = { gen: res.unhappyGen.id, fac: f.id };
|
||||
}
|
||||
this.refreshAll();
|
||||
};
|
||||
return b;
|
||||
};
|
||||
acts.append(
|
||||
mk("🎁 Gift", "gift"), mk("🤝 Alliance", "alliance"),
|
||||
mk(!war ? "⚔ Declare War" : "🕊 Peace", !war ? "declare-war" : "peace", war ? "" : "warn"),
|
||||
mk("👁 Spy", "spy")
|
||||
);
|
||||
if (!war) acts.append(mk("📜 NAP", "nap"), mk("⚖ Trade", "trade"), mk("💍 Marriage", "marriage"), mk("💢 Demand tribute", "demand"));
|
||||
if (G.flags.spyTarget?.fac === f.id) acts.append(mk("💰 Bribe the disgruntled general", "bribe-gen"));
|
||||
card.appendChild(acts);
|
||||
return card;
|
||||
}
|
||||
|
||||
// ---------------- COURT ----------------
|
||||
renderCourt(root) {
|
||||
const pf = playerFaction();
|
||||
root.appendChild(el("div", "panel-h", `The Court of ${esc(pf.name)}`));
|
||||
const statRow = (label, val, desc) => `<div class="bar-wrap" title="${desc}"><span style="min-width:86px">${label}</span><div class="bar-track"><div class="bar-fill" style="width:${clampN(val)}%;background:linear-gradient(90deg,#a8874f,#d8b25f)"></div></div><span>${Math.round(val)}</span></div>`;
|
||||
root.insertAdjacentHTML("beforeend", `
|
||||
<div style="display:flex;gap:14px;align-items:center;margin-bottom:12px">
|
||||
${this.portrait(GEN(pf.leader) ?? { name: pf.name[0], faction: pf.id }, 58)}
|
||||
<div><b style="font-size:15px">${esc(GEN(pf.leader)?.name ?? pf.name)}</b>
|
||||
<div class="gen-title">${pf.rank} of ${esc(pf.name)}</div>
|
||||
<div style="font-size:11.5px;color:#97835b">Origin: ${pf.origin ? D.ORIGINS[pf.origin]?.name : "Historical faction"}</div></div>
|
||||
</div>
|
||||
${statRow("Legitimacy", pf.legitimacy, "How rightful the realm believes your rule is")}
|
||||
${statRow("Fame", Math.min(pf.fame, 100), "Renown across China")}
|
||||
${statRow("Fear", pf.fear, "How much enemies dread you")}
|
||||
${statRow("Honor", pf.honor, "Your reputation for virtue")}
|
||||
${statRow("Corruption", pf.corruption, "High corruption erodes legitimacy")}
|
||||
`);
|
||||
// imperial decisions
|
||||
const dec = el("div", "sub-h", "Imperial Decisions");
|
||||
root.appendChild(dec);
|
||||
const row = el("div", "btn-row");
|
||||
if (G.flags.emperorProtectedBy === pf.id) {
|
||||
row.appendChild(el("span", "trait-tag", "🏯 You protect the Han Emperor (+legitimacy monthly)"));
|
||||
} else if (CITY("luoyang").owner === pf.id) {
|
||||
const pb = el("button", "btn-tiny", "🏯 Protect the Emperor");
|
||||
pb.onclick = () => { this.hooks.onProtectEmperor(); this.refreshAll(); };
|
||||
row.appendChild(pb);
|
||||
}
|
||||
const canEmperor = pf.rank === "King" && pf.legitimacy >= 65;
|
||||
if (canEmperor) {
|
||||
const eb = el("button", "btn-tiny warn", "👑 Proclaim a New Dynasty!");
|
||||
eb.title = "Claim the Mandate of Heaven. The realm will turn on you.";
|
||||
eb.onclick = () => { this.hooks.onProclaimEmperor(); this.refreshAll(); };
|
||||
row.appendChild(eb);
|
||||
}
|
||||
if (G.flags.emperorProtectedBy && G.flags.emperorProtectedBy !== pf.id) {
|
||||
row.appendChild(el("span", "trait-tag", `${esc(F(G.flags.emperorProtectedBy)?.name)} protects the Emperor`));
|
||||
}
|
||||
if (!row.children.length) row.appendChild(el("span", "hint-box", "Grow your rank and legitimacy to unlock imperial decisions."));
|
||||
root.appendChild(row);
|
||||
// prisoners
|
||||
const pris = G.prisoners.filter(p => p.heldBy === pf.id);
|
||||
if (pris.length) {
|
||||
root.appendChild(el("div", "sub-h", "Prisoners of War"));
|
||||
for (const p of pris) {
|
||||
const g = GEN(p.gen);
|
||||
if (!g) continue;
|
||||
const pc = el("div", "gen-card");
|
||||
pc.innerHTML = `${this.portrait(g)}
|
||||
<div class="gen-info"><div class="gen-name">${esc(g.name)}</div>
|
||||
<div class="gen-title">captive since ${p.since} · formerly of ${esc(F(g.faction)?.name ?? "?")}</div></div>`;
|
||||
const pr = el("div", "prisoner-actions");
|
||||
for (const [label, act, cls] of [
|
||||
["⚔ Execute (+Fear −Honor)", "execute-prisoner", "warn"],
|
||||
["🕊 Release (+Honor)", "release-prisoner", ""],
|
||||
[`🤝 Recruit (persuade)`, "recruit-prisoner", ""],
|
||||
["💰 Ransom back", "ransom-prisoner", ""],
|
||||
]) {
|
||||
const b = el("button", "btn-tiny " + cls, label);
|
||||
b.onclick = () => {
|
||||
const res = this.hooks.onPrisoner(act, g.id);
|
||||
this.toast(res.msg, res.ok ? "good" : "bad");
|
||||
this.refreshAll();
|
||||
};
|
||||
pr.appendChild(b);
|
||||
}
|
||||
pc.appendChild(pr);
|
||||
root.appendChild(pc);
|
||||
}
|
||||
}
|
||||
// banquet
|
||||
const brow = el("div", "btn-row");
|
||||
const bb = el("button", "btn-tiny", "🍶 Host Grand Banquet (600g)");
|
||||
bb.title = "+6 loyalty to ALL officers, +1 corruption";
|
||||
bb.onclick = () => { const r = this.hooks.onCourtAction2("banquet"); this.toast(r.msg, r.ok ? "good" : "bad"); this.refreshAll(); };
|
||||
brow.appendChild(bb);
|
||||
root.appendChild(brow);
|
||||
}
|
||||
|
||||
// ---------------- JOURNAL & CHRONICLE ----------------
|
||||
renderJournal(root) {
|
||||
root.appendChild(el("div", "panel-h", "Journal of the Age"));
|
||||
if (!G.journal.length) root.appendChild(el("p", "", "Nothing yet recorded."));
|
||||
for (const j of G.journal.slice(0, 80)) {
|
||||
root.insertAdjacentHTML("beforeend",
|
||||
`<div class="journal-entry ${j.type}"><span class="je-date">${j.y}/${String(j.m).padStart(2, "0")}</span>${esc(j.text)}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
renderChronicle(root) {
|
||||
root.appendChild(el("div", "panel-h", "The Chronicle of Your Dynasty"));
|
||||
const byYear = new Map();
|
||||
for (const c of [...G.chronicle].reverse()) {
|
||||
if (!byYear.has(c.y)) byYear.set(c.y, []);
|
||||
byYear.get(c.y).push(c);
|
||||
}
|
||||
for (const [y, items] of byYear) {
|
||||
root.insertAdjacentHTML("beforeend", `<div class="chron-year">${y}</div>`);
|
||||
for (const c of items) root.insertAdjacentHTML("beforeend", `<div class="journal-entry ${c.type}" style="border-left:none;padding-left:16px">${esc(c.text)}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
renderHelp(root) {
|
||||
root.innerHTML = `
|
||||
<div class="panel-h">How to Play</div>
|
||||
<div class="help-cols">
|
||||
<h4>The Loop</h4>
|
||||
Develop cities → recruit troops → raise armies → march → besiege → repeat. Each END TURN advances one month.
|
||||
<h4>The Map</h4>
|
||||
Drag to orbit, wheel to zoom, WASD to pan. Click a <b>city</b>, <b>army</b>, or <b>province</b>. Colored hexes show ownership.
|
||||
<h4>Cities</h4>
|
||||
Upgrade buildings (farm/market/walls…), recruit units from levies, then pick an idle general and <b>march out</b>.
|
||||
<h4>War</h4>
|
||||
Move armies to adjacent provinces. Attacking opens a cinematic battle — choose formation & stance first. Sieges can also be <b>starved out</b> over months.
|
||||
<h4>Officers</h4>
|
||||
Loyalty drifts monthly. Promote, gift, banquets — ignore them and they defect. Captured officers can be recruited, ransomed, executed…
|
||||
<h4>Diplomacy</h4>
|
||||
Alliances, NAPs, trade, marriage, tribute demands, spying. Betrayal is always available — and never forgotten.
|
||||
<h4>Events</h4>
|
||||
History adapts: Dong Zhuo may fall, Yuan Shu may crown himself, a village boy may be the greatest mind of the age.
|
||||
<h4>Winning</h4>
|
||||
Hold 20 cities, destroy every rival, or claim the Mandate (King rank + Luoyang & Chang'an + legitimacy 80).
|
||||
</div>`;
|
||||
}
|
||||
|
||||
swapPanel(node) {
|
||||
$("#side-panel").classList.remove("hidden");
|
||||
$("#panel-content").innerHTML = "";
|
||||
$("#panel-content").appendChild(node);
|
||||
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
||||
}
|
||||
|
||||
refreshAll() {
|
||||
this.refreshHUD();
|
||||
if (this.activeTab) this.openTab(this.activeTab);
|
||||
}
|
||||
|
||||
// ---------------- TOASTS ----------------
|
||||
toast(text, type = "info") {
|
||||
if (!text) return;
|
||||
const t = el("div", `toast ${type}`, text);
|
||||
$("#toasts").appendChild(t);
|
||||
setTimeout(() => { t.style.opacity = "0"; t.style.transition = "opacity .6s"; }, 4200);
|
||||
setTimeout(() => t.remove(), 4900);
|
||||
}
|
||||
|
||||
// ---------------- EVENT MODALS ----------------
|
||||
showNextEvent(onResolve) {
|
||||
const ev = G.pendingEvents.shift();
|
||||
if (!ev) { onResolve?.(); return false; }
|
||||
const modal = $("#event-modal");
|
||||
$("#event-kind").textContent = ev.kind.toUpperCase();
|
||||
$("#event-title").textContent = ev.title;
|
||||
$("#event-art").textContent = ev.art;
|
||||
$("#event-text").textContent = ev.text;
|
||||
const wrap = $("#event-choices");
|
||||
wrap.innerHTML = "";
|
||||
for (const ch of ev.choices) {
|
||||
const b = el("button", "event-choice");
|
||||
b.innerHTML = `${esc(ch.label)}<span class="ec-hint">${esc(ch.hint ?? "")}</span>`;
|
||||
b.onclick = () => {
|
||||
modal.classList.add("hidden");
|
||||
onResolve?.(ch);
|
||||
};
|
||||
wrap.appendChild(b);
|
||||
}
|
||||
modal.classList.remove("hidden");
|
||||
return true;
|
||||
}
|
||||
|
||||
hasEvents() { return G.pendingEvents.length > 0; }
|
||||
|
||||
// ---------------- TURN BANNER ----------------
|
||||
turnBanner(text) {
|
||||
const b = $("#turn-banner");
|
||||
$("#turn-banner-text").innerHTML = text;
|
||||
b.classList.remove("hidden");
|
||||
clearTimeout(this._bannerT);
|
||||
this._bannerT = setTimeout(() => b.classList.add("hidden"), 1600);
|
||||
}
|
||||
|
||||
// ---------------- BATTLE OVERLAY ----------------
|
||||
openBattleOverlay() { $("#battle-overlay").classList.remove("hidden"); }
|
||||
closeBattleOverlay() { $("#battle-overlay").classList.add("hidden"); $("#battle-result").classList.add("hidden"); }
|
||||
|
||||
updateBattleHUD(rec, roundIdx, round) {
|
||||
const meta = rec.meta;
|
||||
$("#bh-att-name").textContent = meta.atkName;
|
||||
$("#bh-def-name").textContent = meta.defName;
|
||||
$("#bh-att-name").style.color = meta.atkColor;
|
||||
$("#bh-def-name").style.color = meta.defColor;
|
||||
$("#bh-att-morale").style.width = clampN(round?.attMorale ?? 80) + "%";
|
||||
$("#bh-def-morale").style.width = clampN(round?.defMorale ?? 80) + "%";
|
||||
const t0 = rec.initialAtkTotal ?? "?", t1 = rec.initialDefTotal ?? "?";
|
||||
$("#bh-att-troops").textContent = `${fmt(round?.attTroops ?? t0)} / ${fmt(t0)} men`;
|
||||
$("#bh-def-troops").textContent = `${fmt(t1)} / ${fmt(round?.defTroops ?? t1)} men`;
|
||||
$("#bh-round").textContent = rec.kind === "siege" ? `ASSAULT ${roundIdx + 1}/${rec.rounds.length}` : `ROUND ${roundIdx + 1}/${rec.rounds.length}`;
|
||||
$("#bh-terrain").textContent = `${meta.terrainName ?? ""}${meta.walls ? ` · walls lv${meta.walls}` : ""}`;
|
||||
}
|
||||
|
||||
battleCallout(html) {
|
||||
const c = $("#battle-callout");
|
||||
c.innerHTML = html ? `<div class="callout">${html}</div>` : "";
|
||||
}
|
||||
|
||||
showBattleResult(rec, extraHTML, onClose) {
|
||||
const won = rec.playerWon;
|
||||
const box = $("#battle-result");
|
||||
box.classList.remove("hidden");
|
||||
box.innerHTML = `
|
||||
<div class="br-card">
|
||||
<h2 class="${won ? "br-victory" : "br-defeat"}">${won ? "VICTORY" : "DEFEAT"} — ${esc(rec.title)}</h2>
|
||||
<div class="br-lines">
|
||||
Your losses: <b>${fmt(rec.playerLosses ?? 0)}</b> · Enemy losses: <b>${fmt(rec.enemyLosses ?? 0)}</b><br>
|
||||
${extraHTML ?? ""}
|
||||
</div>
|
||||
<button class="btn btn-primary" id="br-close">Continue</button>
|
||||
</div>`;
|
||||
$("#br-close").onclick = () => { box.classList.add("hidden"); onClose?.(); };
|
||||
}
|
||||
}
|
||||
|
||||
function clampN(v) { return Math.max(0, Math.min(100, Number(v) || 0)); }
|
||||
Vendored
+53044
File diff suppressed because one or more lines are too long
+74
@@ -0,0 +1,74 @@
|
||||
// ============================================================
|
||||
// WORLD — derived geometry from the hex map (adjacency, centroids)
|
||||
// ============================================================
|
||||
import { MAP_ROWS, HEX_R, hexToWorld, PROVINCES } from "./data.js";
|
||||
|
||||
export const HEX_W = Math.sqrt(3) * HEX_R;
|
||||
|
||||
function nbrs(c, r) {
|
||||
const odd = r % 2;
|
||||
return [[c - 1, r], [c + 1, r], [c + odd - 1, r - 1], [c + odd, r - 1], [c + odd - 1, r + 1], [c + odd, r + 1]];
|
||||
}
|
||||
|
||||
const rows = MAP_ROWS;
|
||||
const H = rows.length;
|
||||
const W = Math.max(...rows.map(r => r.length));
|
||||
const at = (c, r) => (r < 0 || r >= H || c < 0 || c >= (rows[r]?.length ?? 0)) ? "." : rows[r][c];
|
||||
|
||||
export const hexes = [];
|
||||
for (let r = 0; r < H; r++) {
|
||||
for (let c = 0; c < rows[r].length; c++) {
|
||||
const ch = rows[r][c];
|
||||
if (ch === ".") continue;
|
||||
const [x, z] = hexToWorld(c, r);
|
||||
hexes.push({ col: c, row: r, ch, prov: ch.toLowerCase(), mountain: ch !== ch.toLowerCase(), x, z });
|
||||
}
|
||||
}
|
||||
|
||||
// province adjacency
|
||||
export const PROV_ADJ = {};
|
||||
for (const p of Object.keys(PROVINCES)) PROV_ADJ[p] = new Set();
|
||||
const key = (c, r) => c + "," + r;
|
||||
const provAt = (c, r) => { const ch = at(c, r); return ch === "." ? null : ch.toLowerCase(); };
|
||||
for (const h of hexes) {
|
||||
for (const [nc, nr] of nbrs(h.col, h.row)) {
|
||||
const q = provAt(nc, nr);
|
||||
if (q && q !== h.prov) { PROV_ADJ[h.prov].add(q); PROV_ADJ[q].add(h.prov); }
|
||||
}
|
||||
}
|
||||
export const provNeighbors = p => [...PROV_ADJ[p]];
|
||||
|
||||
// centroids (average of hexes, biased toward cities)
|
||||
export const PROV_CENTROID = {};
|
||||
for (const p of Object.keys(PROVINCES)) {
|
||||
let sx = 0, sz = 0, n = 0;
|
||||
for (const h of hexes) if (h.prov === p) { sx += h.x; sz += h.z; n++; }
|
||||
PROV_CENTROID[p] = n ? [sx / n, sz / n] : [0, 0];
|
||||
}
|
||||
// pull centroid toward cities so markers sit near population
|
||||
import { CITY_DEFS } from "./data.js";
|
||||
for (const p of Object.keys(PROVINCES)) {
|
||||
const cs = CITY_DEFS.filter(c => c[3] === p);
|
||||
if (!cs.length) continue;
|
||||
const [cx, cz] = PROV_CENTROID[p];
|
||||
let bx = 0, bz = 0;
|
||||
for (const [, , , , col, row] of cs) { const [x, z] = hexToWorld(col, row); bx += x; bz += z; }
|
||||
bx /= cs.length; bz /= cs.length;
|
||||
PROV_CENTROID[p] = [cx * 0.45 + bx * 0.55, cz * 0.45 + bz * 0.55];
|
||||
}
|
||||
|
||||
// distance between provinces (for AI threat estimates)
|
||||
export function provDist(a, b, depth = 0, seen = new Set()) {
|
||||
if (a === b) return 0;
|
||||
if (PROV_ADJ[a]?.has(b)) return 1;
|
||||
seen.add(a);
|
||||
let best = Infinity;
|
||||
for (const n of PROV_ADJ[a] || []) {
|
||||
if (seen.has(n)) continue;
|
||||
best = Math.min(best, 1 + provDist(n, b, depth + 1, seen));
|
||||
if (depth > 6) break;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function adjacentProvinces(a, b) { return !!PROV_ADJ[a]?.has(b); }
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Programmatic visual review of screenshots: OCR geometry, contrast, palette, composition."""
|
||||
import sys, subprocess, csv, io, math
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
SHOTS = sys.argv[1:]
|
||||
LANG = "eng+chi_sim"
|
||||
|
||||
def rel_lum(arr):
|
||||
a = arr.astype(np.float64) / 255.0
|
||||
def f(c): return np.where(c <= 0.03928, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
|
||||
return 0.2126 * f(a[..., 0]) + 0.7152 * f(a[..., 1]) + 0.0722 * f(a[..., 2])
|
||||
|
||||
def cr(l1, l2):
|
||||
hi, lo = max(l1, l2), min(l1, l2)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
|
||||
def hexc(rgb):
|
||||
return "#%02x%02x%02x" % tuple(int(x) for x in rgb)
|
||||
|
||||
def ocr_lines(img_path):
|
||||
"""Return list of (text, x,y,w,h, conf) grouped per line."""
|
||||
p = subprocess.run(
|
||||
["tesseract", img_path, "stdout", "-l", LANG, "--psm", "11", "tsv"],
|
||||
capture_output=True, text=True)
|
||||
rows = list(csv.DictReader(io.StringIO(p.stdout), delimiter="\t"))
|
||||
lines = {}
|
||||
for r in rows:
|
||||
try:
|
||||
conf = float(r["conf"])
|
||||
except Exception:
|
||||
continue
|
||||
if conf < 35 or not r["text"].strip():
|
||||
continue
|
||||
key = (r["block_num"], r["par_num"], r["line_num"])
|
||||
e = lines.setdefault(key, {"words": [], "x0": 10**9, "y0": 10**9, "x1": -1, "y1": -1, "confs": []})
|
||||
x, y, w, h = int(r["left"]), int(r["top"]), int(r["width"]), int(r["height"])
|
||||
e["words"].append((r["text"], conf))
|
||||
e["confs"].append(conf)
|
||||
e["x0"] = min(e["x0"], x); e["y0"] = min(e["y0"], y)
|
||||
e["x1"] = max(e["x1"], x + w); e["y1"] = max(e["y1"], y + h)
|
||||
out = []
|
||||
for k in sorted(lines, key=lambda k: (lines[k]["y0"], lines[k]["x0"])):
|
||||
e = lines[k]
|
||||
out.append({"text": " ".join(w for w, _ in e["words"]),
|
||||
"bbox": (e["x0"], e["y0"], e["x1"] - e["x0"], e["y1"] - e["y0"]),
|
||||
"conf": sum(e["confs"]) / len(e["confs"])})
|
||||
return out
|
||||
|
||||
def analyze(path):
|
||||
im = Image.open(path).convert("RGB")
|
||||
W, H = im.size
|
||||
arr = np.asarray(im)
|
||||
L = rel_lum(arr)
|
||||
gray = np.asarray(im.convert("L"))
|
||||
|
||||
print(f"\n{'='*78}\n{path} ({W}x{H})\n{'='*78}")
|
||||
|
||||
# Global tone
|
||||
print(f"luminance mean={L.mean():.3f} p5={np.percentile(L,5):.3f} p50={np.percentile(L,50):.3f} "
|
||||
f"p95={np.percentile(L,95):.3f} | dark(<0.15)={100*(L<0.15).mean():.0f}% bright(>0.85)={100*(L>0.85).mean():.0f}%")
|
||||
|
||||
# Palette
|
||||
q = im.quantize(colors=8, method=Image.Quantize.MEDIANCUT)
|
||||
pal = q.getpalette()
|
||||
counts = sorted(q.getcolors(W * H), reverse=True)
|
||||
tot = W * H
|
||||
tops = []
|
||||
for cnt, idx in counts[:8]:
|
||||
rgb = pal[idx*3:idx*3+3]
|
||||
tops.append(f"{hexc(rgb)} {100*cnt/tot:.0f}%")
|
||||
print("palette: " + " ".join(tops))
|
||||
|
||||
# Composition grid: 16 cols x 10 rows -> mean luminance (0-9) and dominant hue class letter
|
||||
GX, GY = 16, 10
|
||||
print("composition grid (mean lum 0-9; hue: R=red/warm Y=yellow/tan G=green C=cyan B=blue M=magenta K=neutral-dark N=neutral-mid W=white-ish):")
|
||||
hsv = np.asarray(im.convert("HSV")).astype(int)
|
||||
hh, ss, vv = hsv[..., 0], hsv[..., 1], hsv[..., 2]
|
||||
for gy in range(GY):
|
||||
row = ""
|
||||
for gx in range(GX):
|
||||
cell = L[gy*H//GY:(gy+1)*H//GY, gx*W//GX:(gx+1)*W//GX]
|
||||
m = cell.mean()
|
||||
# dominant hue of saturated pixels
|
||||
ch = hh[gy*H//GY:(gy+1)*H//GY, gx*W//GX:(gx+1)*W//GX]
|
||||
cs = ss[gy*H//GY:(gy+1)*H//GY, gx*W//GX:(gx+1)*W//GX]
|
||||
sat = cs > 60
|
||||
if m > 0.85: c = "W"
|
||||
elif sat.mean() > 0.12:
|
||||
ang = np.median(ch[sat]) * 360 / 255
|
||||
c = "R" if ang < 25 or ang >= 330 else "Y" if ang < 70 else "G" if ang < 160 else "C" if ang < 200 else "B" if ang < 270 else "M"
|
||||
elif m > 0.45: c = "N"
|
||||
else: c = "K"
|
||||
row += str(min(9, int(m * 10))) + c
|
||||
print(" " + row)
|
||||
|
||||
# Low-variance (flat) regions — candidate dead space
|
||||
flat = []
|
||||
for gy in range(GY):
|
||||
for gx in range(GX):
|
||||
cell = gray[gy*H//GY:(gy+1)*H//GY, gx*W//GX:(gx+1)*W//GX]
|
||||
if cell.std() < 6:
|
||||
flat.append((gx, gy, round(float(cell.mean()))))
|
||||
print(f"flat cells (std<6): {len(flat)}/{GX*GY}" + (f" at {flat[:12]}" if flat else ""))
|
||||
|
||||
# OCR line analysis
|
||||
lines = ocr_lines(path)
|
||||
print(f"OCR: {len(lines)} lines (conf>=35)")
|
||||
low_contrast, overlaps, tiny = [], [], []
|
||||
boxes = []
|
||||
for ln in lines:
|
||||
x, y, w, h = ln["bbox"]
|
||||
if w <= 1 or h <= 1 or x < 0 or y < 0 or x + w > W or y + h > H:
|
||||
continue
|
||||
pad = 2
|
||||
x0, y0 = max(0, x - pad), max(0, y - pad)
|
||||
x1, y1 = min(W, x + w + pad), min(H, y + h + pad)
|
||||
reg = L[y0:y1, x0:x1]
|
||||
lo, hi = np.percentile(reg, 5), np.percentile(reg, 95)
|
||||
ratio = cr(hi, lo)
|
||||
boxes.append((x, y, w, h, ln["text"]))
|
||||
if h < 9 and ln["conf"] > 50:
|
||||
tiny.append(ln)
|
||||
if ratio < 4.0 and len(ln["text"].strip()) > 1:
|
||||
low_contrast.append((ln["text"][:40], (x, y, w, h), round(ratio, 2)))
|
||||
# overlap between different lines (>25% of smaller area)
|
||||
for i in range(len(boxes)):
|
||||
for j in range(i + 1, len(boxes)):
|
||||
ax, ay, aw, ah, at = boxes[i]; bx, by, bw, bh, bt = boxes[j]
|
||||
ox = max(0, min(ax+aw, bx+bw) - max(ax, bx)); oy = max(0, min(ay+ah, by+bh) - max(ay, by))
|
||||
inter = ox * oy
|
||||
small = min(aw*ah, bw*bh)
|
||||
if small and inter / small > 0.30 and at != bt:
|
||||
overlaps.append((at[:28], bt[:28], round(100*inter/small)))
|
||||
if low_contrast:
|
||||
print("LOW CONTRAST (<4:1):")
|
||||
for t, b, r in low_contrast[:14]:
|
||||
print(f" {r:>5}:1 @({b[0]},{b[1]},{b[2]}x{b[3]}) '{t}'")
|
||||
if len(low_contrast) > 14: print(f" ... +{len(low_contrast)-14} more")
|
||||
else:
|
||||
print("LOW CONTRAST: none flagged")
|
||||
if overlaps:
|
||||
print("OVERLAPPING TEXT BOXES:")
|
||||
for a, b, pct in overlaps[:10]:
|
||||
print(f" {pct}% '{a}' vs '{b}'")
|
||||
if tiny:
|
||||
print(f"VERY SMALL TEXT (<9px tall): {len(tiny)} e.g. " + "; ".join(t['text'][:18] for t in tiny[:5]))
|
||||
|
||||
# Print all OCR lines with geometry so reviewer can reconstruct layout
|
||||
print("--- OCR transcript (x,y w h | conf | text) ---")
|
||||
for ln in lines:
|
||||
x, y, w, h = ln["bbox"]
|
||||
print(f" ({x:>4},{y:>4} {w:>4}x{h:<3}|{ln['conf']:.0f}) {ln['text'][:90]}")
|
||||
|
||||
for p in SHOTS:
|
||||
try:
|
||||
analyze(p)
|
||||
except Exception as e:
|
||||
print(f"\nFAILED {p}: {type(e).__name__}: {e}")
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Zoom into a region: upscale, stats, OCR."""
|
||||
import sys, subprocess
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
path, x, y, w, h = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4]), int(sys.argv[5])
|
||||
scale = int(sys.argv[6]) if len(sys.argv) > 6 else 3
|
||||
im = Image.open(path).convert("RGB")
|
||||
W, H = im.size
|
||||
x0, y0 = max(0, x), max(0, y)
|
||||
x1, y1 = min(W, x + w), min(H, y + h)
|
||||
crop = im.crop((x0, y0, x1, y1))
|
||||
arr = np.asarray(crop).astype(float)
|
||||
print(f"region ({x0},{y0})-({x1},{y1}): mean RGB={arr.reshape(-1,3).mean(0).round(1)} "
|
||||
f"lum={0.2126*arr[...,0].mean()/255+0.7152*arr[...,1].mean()/255+0.0722*arr[...,2].mean()/255:.3f} "
|
||||
f"std={arr.std():.1f} max={arr.max():.0f}")
|
||||
# brightest structures: threshold above local background
|
||||
g = np.asarray(crop.convert("L"))
|
||||
for pct in (99, 99.9):
|
||||
print(f" p{pct} gray={np.percentile(g, pct):.0f}")
|
||||
big = crop.resize((crop.width * scale, crop.height * scale), Image.LANCZOS)
|
||||
big.save("/tmp/crop.png")
|
||||
p = subprocess.run(["tesseract", "/tmp/crop.png", "stdout", "-l", "eng+chi_sim", "--psm", "11"],
|
||||
capture_output=True, text=True)
|
||||
txt = [ln.strip() for ln in p.stdout.splitlines() if ln.strip() and len(ln.strip()) > 1]
|
||||
print("zoomed OCR:", txt if txt else "(nothing)")
|
||||
@@ -0,0 +1,35 @@
|
||||
|
||||
==============================================================================
|
||||
shots/01-title.png (1280x800)
|
||||
==============================================================================
|
||||
luminance mean=0.019 p5=0.004 p50=0.008 p95=0.022 | dark(<0.15)=98% bright(>0.85)=0%
|
||||
palette: #271e12 25% #1b140c 14% #181009 13% #22170d 12% #1d1009 11% #130e09 11% #0f0a06 10% #433725 4%
|
||||
composition grid (mean lum 0-9; hue: R=red/warm Y=yellow/tan G=green C=cyan B=blue M=magenta K=neutral-dark N=neutral-mid W=white-ish):
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y1Y1Y1Y1Y2Y1Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y1Y1Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0R0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0R0R0R0R0R0R0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0R0R0R0R0R0R0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0R0R0R0R0R0R0R0R0R0Y0Y0Y0Y0Y0Y0Y
|
||||
0R0R0R0R0R0R0R0R0R0Y0Y0Y0Y0Y0Y0Y
|
||||
flat cells (std<6): 109/160 at [(0, 0, 16), (1, 0, 16), (2, 0, 17), (3, 0, 19), (4, 0, 21), (5, 0, 23), (6, 0, 26), (7, 0, 28), (8, 0, 30), (9, 0, 32), (10, 0, 33), (11, 0, 34)]
|
||||
OCR: 11 lines (conf>=35)
|
||||
LOW CONTRAST (<4:1):
|
||||
1.91:1 @(972,99,56x29) '群雄'
|
||||
3.55:1 @(329,703,8x6) 'ri'
|
||||
VERY SMALL TEXT (<9px tall): 1 e.g. ri
|
||||
--- OCR transcript (x,y w h | conf | text) ---
|
||||
( 972, 99 56x29 |76) 群雄
|
||||
( 391, 104 483x47 |65) WARLORD’S
|
||||
( 542, 190 188x46 |96) FATE
|
||||
( 350, 272 37x15 |63) =
|
||||
( 427, 272 498x18 |89) We — A Three Kingdoms Chronicle
|
||||
( 440, 320 394x16 |94) ‘The Han collapses. Warlords rise. History is unwritten
|
||||
( 317, 347 641x16 |96) Take a city, a banner, and a handful of sworn blades — and carve your name into the age.
|
||||
( 545, 466 190x14 |84) 加 HUNDRED DAYS TRIAL
|
||||
( 577, 576 144x10 |97) LOAD SAVED GAME
|
||||
( 577, 631 125x11 |90) ? HOW TO PLAY
|
||||
( 329, 703 8x6 |89) ri
|
||||
@@ -0,0 +1,48 @@
|
||||
|
||||
==============================================================================
|
||||
shots/03-setup.png (1280x800)
|
||||
==============================================================================
|
||||
luminance mean=0.011 p5=0.002 p50=0.006 p95=0.017 | dark(<0.15)=99% bright(>0.85)=0%
|
||||
palette: #1e150c 19% #090603 18% #140e07 16% #191109 14% #0c0804 12% #1b120b 10% #1d140c 6% #4d3725 4%
|
||||
composition grid (mean lum 0-9; hue: R=red/warm Y=yellow/tan G=green C=cyan B=blue M=magenta K=neutral-dark N=neutral-mid W=white-ish):
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0R0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0R0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0R0R0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
flat cells (std<6): 39/160 at [(0, 0, 8), (15, 0, 8), (0, 1, 8), (4, 1, 22), (11, 1, 22), (12, 1, 22), (13, 1, 22), (15, 1, 8), (0, 2, 8), (13, 2, 23), (15, 2, 8), (0, 3, 8)]
|
||||
OCR: 20 lines (conf>=35)
|
||||
LOW CONTRAST (<4:1):
|
||||
3.72:1 @(145,274,581x39) '| Local Governor — An appointed administ'
|
||||
3.41:1 @(582,371,453x32) '| | Xuchang: Yuzhou ‘Wan Yuzhou Lujiang '
|
||||
3.47:1 @(146,417,137x34) 'Banner emblem | x'
|
||||
3.46:1 @(609,460,472x38) '| Rich plains at the crossroads of the C'
|
||||
3.13:1 @(146,505,233x32) 'Faction name (optional) | House of'
|
||||
3.07:1 @(160,601,415x43) 'Shepherd of the People |[ |) Vulture of '
|
||||
2.64:1 @(349,660,299x50) '| ||'
|
||||
--- OCR transcript (x,y w h | conf | text) ---
|
||||
( 446, 98 385x23 |87) RAISE YOUR BANNER *%
|
||||
( 146, 150 115x16 |76) I. Origin 出身
|
||||
( 160, 185 859x31 |80) Local Governor | X Former Soldier | | 加 Fallen Noble | | DBandit Leader | | Merchant Princ
|
||||
( 145, 230 291x31 |76) | Exiled General || Firebrand |
|
||||
( 145, 274 581x39 |92) | Local Governor — An appointed administrator. +Politics, cities start orderly, cheap buil
|
||||
( 146, 336 96x17 |74) I. Identity
|
||||
( 610, 337 146x16 |87) III. Starting City
|
||||
( 582, 371 453x32 |75) | | Xuchang: Yuzhou ‘Wan Yuzhou Lujiang - Yangzhou |
|
||||
( 146, 381 72x11 |95) Ruler name
|
||||
( 240, 381 36x12 |96) Minh
|
||||
( 609, 416 458x31 |81) | Changsha Jingzhou | Yunnan - Yizhou Anding Yongzhou |
|
||||
( 146, 417 137x34 |93) Banner emblem | x
|
||||
( 609, 460 472x38 |92) | Rich plains at the crossroads of the Central Plains — everyone's future prize
|
||||
( 242, 465 47x26 |54) |
|
||||
( 146, 472 81x11 |94) Banner color
|
||||
( 146, 505 233x32 |92) Faction name (optional) | House of
|
||||
( 146, 573 114x16 |97) IV. Difficulty
|
||||
( 160, 601 415x43 |87) Shepherd of the People |[ |) Vulture of the Age |
|
||||
( 349, 660 299x50 |65) | ||
|
||||
( 767, 680 39x10 |96) BACK
|
||||
@@ -0,0 +1,96 @@
|
||||
|
||||
==============================================================================
|
||||
shots/04-game.png (1280x800)
|
||||
==============================================================================
|
||||
luminance mean=0.062 p5=0.004 p50=0.045 p95=0.149 | dark(<0.15)=95% bright(>0.85)=0%
|
||||
palette: #6f5a3f 16% #0f0b0a 14% #443021 13% #4f4330 13% #332519 13% #6d4e37 11% #736d4e 10% #1c150f 10%
|
||||
composition grid (mean lum 0-9; hue: R=red/warm Y=yellow/tan G=green C=cyan B=blue M=magenta K=neutral-dark N=neutral-mid W=white-ish):
|
||||
0Y0Y0Y0Y0Y0Y0Y0B0B0B0Y0Y0Y0Y0R0R
|
||||
0Y0Y0Y0Y0R0R0R0R0Y0Y0Y0Y0Y0M0M0M
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y1Y1Y0Y0Y0Y0Y0Y0Y0R0Y0Y0Y0Y0Y
|
||||
0Y1Y1Y1Y0Y0Y0Y0Y0R0R0R0R0Y1Y1Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0R0Y0R1R0R1Y1Y1Y
|
||||
0Y0Y0Y0Y0Y0Y0R0R1R0R0Y0Y0Y0Y1Y1Y
|
||||
0Y0Y0Y1Y1G0Y0Y0Y0G0Y0G0G0Y0Y0Y0Y
|
||||
0Y0Y1Y1Y0Y0Y0Y0Y1G1G1G1G0G0Y0Y0Y
|
||||
0Y0Y0Y0G0Y0Y0Y0Y0G0Y0Y0G0Y0Y0Y0G
|
||||
flat cells (std<6): 11/160 at [(9, 0, 12), (10, 0, 12), (11, 0, 12), (0, 3, 75), (2, 3, 89), (15, 3, 75), (14, 4, 98), (15, 4, 88), (14, 5, 107), (15, 5, 95), (15, 6, 97)]
|
||||
OCR: 59 lines (conf>=35)
|
||||
LOW CONTRAST (<4:1):
|
||||
2.22:1 @(55,20,8x8) 'TE'
|
||||
3.81:1 @(28,89,179x14) 'China, or be crowned Emperor.'
|
||||
2.21:1 @(628,142,41x11) 'Beihai'
|
||||
1.91:1 @(956,218,48x22) '=a'
|
||||
3.14:1 @(709,237,23x7) '‘own:'
|
||||
2.38:1 @(968,312,61x16) 'el'
|
||||
2.49:1 @(635,315,46x16) 'ae'
|
||||
3.11:1 @(486,340,9x7) 'tn'
|
||||
2.43:1 @(853,368,17x19) 'Ay'
|
||||
2.46:1 @(870,380,20x10) '/四'
|
||||
2.84:1 @(258,390,91x70) 'by'
|
||||
2.48:1 @(927,397,47x43) '“3'
|
||||
2.69:1 @(681,457,42x14) '44,'
|
||||
2.77:1 @(533,479,39x22) 'a a'
|
||||
... +13 more
|
||||
VERY SMALL TEXT (<9px tall): 2 e.g. TE; lage
|
||||
--- OCR transcript (x,y w h | conf | text) ---
|
||||
( 354, 16 60x16 |65) 四 7,000
|
||||
( 91, 17 129x14 |89) Winter 193 - Jan
|
||||
( 273, 18 41x14 |96) 2,800
|
||||
( 471, 18 41x14 |96) 2,500
|
||||
( 546, 18 20x12 |83) o1
|
||||
( 601, 18 21x12 |53) 03
|
||||
( 55, 20 8x8 |96) TE
|
||||
( 27, 70 229x14 |96) Victory: hold 20 cities (now 1), or unite
|
||||
( 28, 89 179x14 |94) China, or be crowned Emperor.
|
||||
( 28, 108 209x14 |86) Hold Chang'an & Luoyang with high
|
||||
( 28, 128 186x14 |96) legitimacy to claim the Mandate.
|
||||
( 628, 142 41x11 |96) Beihai
|
||||
( 927, 171 6x6 |42) Ww
|
||||
( 381, 191 29x15 |70) ping
|
||||
( 956, 218 48x22 |41) =a
|
||||
( 709, 237 23x7 |40) ‘own:
|
||||
( 953, 243 13x12 |82) a
|
||||
( 714, 247 23x11 |44) suche
|
||||
( 968, 312 61x16 |43) el
|
||||
( 701, 313 63x16 |57) +
|
||||
( 635, 315 46x16 |52) ae
|
||||
( 464, 322 31x13 |37) Luoy:
|
||||
( 820, 323 31x24 |37) i
|
||||
( 486, 340 9x7 |47) tn
|
||||
( 725, 351 22x10 |74) ang
|
||||
( 853, 368 17x19 |52) Ay
|
||||
( 963, 374 17x10 |37) ~
|
||||
( 870, 380 20x10 |54) /四
|
||||
( 258, 390 91x70 |63) by
|
||||
( 927, 397 47x43 |43) “3
|
||||
(1048, 410 25x10 |82) ~
|
||||
( 197, 418 46x14 |86) Anding
|
||||
( 853, 430 73x33 |57) Vy 4,
|
||||
(1043, 433 6x24 |37) r
|
||||
( 681, 457 42x14 |50) 44,
|
||||
( 745, 476 60x56 |39) a
|
||||
( 533, 479 39x22 |77) a a
|
||||
( 819, 489 30x11 |81) zhou
|
||||
( 527, 511 27x20 |43) Ae
|
||||
( 729, 560 4x10 |92) 全
|
||||
( 512, 571 21x10 |97) ong
|
||||
( 857, 585 37x22 |68) 4g
|
||||
(1119, 585 49x10 |86) Yunnan
|
||||
( 511, 587 24x8 |64) lage
|
||||
( 648, 600 16x11 |50) du
|
||||
( 799, 617 51x29 |65) on
|
||||
( 790, 648 59x20 |38) ows,
|
||||
( 923, 651 52x26 |82) on
|
||||
( 731, 660 27x20 |57) %
|
||||
( 538, 666 28x13 |69) fa
|
||||
( 666, 678 312x42 |47) ae ~* 有
|
||||
( 287, 705 57x62 |63) “a
|
||||
( 236, 777 169x14 |75) & Generals 0 Cities
|
||||
( 870, 777 85x14 |87) & Chronicle
|
||||
( 545, 778 87x14 |49) Diplomacy
|
||||
( 766, 778 63x13 |50) DJournal
|
||||
( 997, 778 47x14 |87) 2 Help
|
||||
( 457, 779 48x12 |75) Armies
|
||||
( 672, 779 54x10 |86) = Court
|
||||
@@ -0,0 +1,21 @@
|
||||
|
||||
==============================================================================
|
||||
shots/05-generals.png (1280x800)
|
||||
==============================================================================
|
||||
luminance mean=0.049 p5=0.003 p50=0.021 p95=0.146 | dark(<0.15)=96% bright(>0.85)=0%
|
||||
palette: #19120a 17% #71553c 17% #3a2e20 14% #130e08 13% #59422d 11% #0e0a06 10% #271a11 9% #786d4e 9%
|
||||
composition grid (mean lum 0-9; hue: R=red/warm Y=yellow/tan G=green C=cyan B=blue M=magenta K=neutral-dark N=neutral-mid W=white-ish):
|
||||
0Y0Y0Y0Y0R0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y1Y1Y1Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y1Y1Y1Y0R0Y0Y0Y1R0R0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0R0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0R0R1R0R0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y1Y1G0Y0Y0Y0R0R0Y0Y0Y0Y0Y0Y
|
||||
0Y1Y1Y1Y0G0Y0Y0Y0G1G0G0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0G0Y0Y0Y0Y0Y0Y
|
||||
flat cells (std<6): 2/160 at [(15, 1, 14), (15, 7, 14)]
|
||||
OCR: 0 lines (conf>=35)
|
||||
LOW CONTRAST: none flagged
|
||||
--- OCR transcript (x,y w h | conf | text) ---
|
||||
@@ -0,0 +1,21 @@
|
||||
|
||||
==============================================================================
|
||||
shots/07-diplomacy.png (1280x800)
|
||||
==============================================================================
|
||||
luminance mean=0.049 p5=0.003 p50=0.020 p95=0.145 | dark(<0.15)=96% bright(>0.85)=0%
|
||||
palette: #71563d 17% #1a130b 16% #3a2e1f 14% #151009 12% #271a11 11% #0f0a06 11% #58412d 11% #796c4f 9%
|
||||
composition grid (mean lum 0-9; hue: R=red/warm Y=yellow/tan G=green C=cyan B=blue M=magenta K=neutral-dark N=neutral-mid W=white-ish):
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y1Y1Y1Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y1Y1Y1Y0Y0Y0Y0Y1R0R0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0R0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0R0R0R0R0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y1Y1G0Y0Y0R1R1R0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y1Y1Y0G0Y0Y0Y0G1G0G0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0G0Y0Y0Y0Y0Y0Y
|
||||
flat cells (std<6): 6/160 at [(0, 3, 82), (15, 3, 17), (15, 5, 17), (12, 6, 20), (15, 6, 17), (15, 8, 14)]
|
||||
OCR: 0 lines (conf>=35)
|
||||
LOW CONTRAST: none flagged
|
||||
--- OCR transcript (x,y w h | conf | text) ---
|
||||
@@ -0,0 +1,35 @@
|
||||
|
||||
==============================================================================
|
||||
shots/14-battle-late.png (1280x800)
|
||||
==============================================================================
|
||||
luminance mean=0.058 p5=0.005 p50=0.055 p95=0.133 | dark(<0.15)=100% bright(>0.85)=0%
|
||||
palette: #261b16 19% #5b4528 15% #7d6136 13% #684f2d 12% #735731 12% #201613 11% #462b1d 9% #130c0a 7%
|
||||
composition grid (mean lum 0-9; hue: R=red/warm Y=yellow/tan G=green C=cyan B=blue M=magenta K=neutral-dark N=neutral-mid W=white-ish):
|
||||
0R0R0R0R0R0R0R0R0R0R0R0R0R0R0R0R
|
||||
0R0R0R0R0R0R0R0R0R0R0R0R0R0R0R0R
|
||||
0R0R0R0R0R0R0R0R0R0R0R0R0R0R0R0R
|
||||
0R0R0R0R0R0R0R0R0R0R0R0R0R0R0R0R
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y1Y1Y1Y0Y0R0R0R1Y1Y1Y1Y1Y0Y0Y
|
||||
0Y0Y1Y1Y1Y0Y1Y1Y1Y1Y1Y1Y1Y1Y0Y0Y
|
||||
0Y0Y0Y1Y1Y1Y1Y1Y1Y1Y1Y1Y1Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y1Y1Y1Y1Y1Y1Y0Y0Y0Y0Y0Y
|
||||
flat cells (std<6): 114/160 at [(5, 0, 16), (6, 0, 17), (9, 0, 17), (10, 0, 16), (0, 1, 20), (1, 1, 22), (2, 1, 24), (3, 1, 26), (4, 1, 28), (5, 1, 28), (6, 1, 29), (7, 1, 29)]
|
||||
OCR: 9 lines (conf>=35)
|
||||
LOW CONTRAST (<4:1):
|
||||
1.7:1 @(576,374,50x44) 'mi'
|
||||
2.32:1 @(540,431,88x69) 'Wi \'
|
||||
2.43:1 @(486,474,46x60) 'mi'
|
||||
1.3:1 @(533,488,7x24) 'i)'
|
||||
2.64:1 @(556,506,42x54) 'the'
|
||||
--- OCR transcript (x,y w h | conf | text) ---
|
||||
( 568, 17 141x14 |96) ASSAULT 7/9
|
||||
( 149, 18 39x9 |96) HOST
|
||||
(1052, 18 81x9 |95) GARRISON
|
||||
( 576, 374 50x44 |39) mi
|
||||
( 540, 431 88x69 |49) Wi \
|
||||
( 486, 474 46x60 |46) mi
|
||||
( 533, 488 7x24 |40) i)
|
||||
( 551, 498 3x20 |62) )
|
||||
( 556, 506 42x54 |44) the
|
||||
@@ -0,0 +1,21 @@
|
||||
|
||||
==============================================================================
|
||||
shots/16-end.png (1100x700)
|
||||
==============================================================================
|
||||
luminance mean=0.012 p5=0.002 p50=0.006 p95=0.011 | dark(<0.15)=99% bright(>0.85)=0%
|
||||
palette: #191108 20% #1e150c 18% #090603 16% #150e08 14% #1c130b 12% #1d140c 9% #17110a 6% #3a2a1c 4%
|
||||
composition grid (mean lum 0-9; hue: R=red/warm Y=yellow/tan G=green C=cyan B=blue M=magenta K=neutral-dark N=neutral-mid W=white-ish):
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0R0R0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y0Y
|
||||
flat cells (std<6): 67/160 at [(0, 0, 17), (1, 0, 19), (2, 0, 21), (3, 0, 22), (4, 0, 24), (5, 0, 25), (6, 0, 27), (7, 0, 27), (8, 0, 27), (9, 0, 27), (10, 0, 25), (11, 0, 24)]
|
||||
OCR: 0 lines (conf>=35)
|
||||
LOW CONTRAST: none flagged
|
||||
--- OCR transcript (x,y w h | conf | text) ---
|
||||
@@ -0,0 +1,61 @@
|
||||
// map validation script (node)
|
||||
import { MAP_ROWS, PROVINCE_LETTERS, CITY_DEFS, hexToWorld, RIVERS } from "../js/data.js";
|
||||
|
||||
const rows = MAP_ROWS;
|
||||
const H = rows.length;
|
||||
const at = (c, r) => (r < 0 || r >= H || c < 0 || c >= (rows[r]?.length ?? 0)) ? "." : rows[r][c];
|
||||
const isLand = ch => ch !== ".";
|
||||
const provOf = ch => isLand(ch) ? ch.toLowerCase() : null;
|
||||
|
||||
// neighbors odd-r offset
|
||||
function nbrs(c, r) {
|
||||
const odd = r % 2;
|
||||
return [[c-1,r],[c+1,r],[c+odd-1,r-1],[c+odd,r-1],[c+odd-1,r+1],[c+odd,r+1]];
|
||||
}
|
||||
|
||||
// contiguity per province
|
||||
for (const P of PROVINCE_LETTERS) {
|
||||
const cells = [];
|
||||
for (let r = 0; r < H; r++) for (let c = 0; c < (rows[r].length); c++) if (provOf(rows[r][c]) === P) cells.push([c, r]);
|
||||
if (!cells.length) { console.log(`!! province ${P} EMPTY`); continue; }
|
||||
const set = new Set(cells.map(([c, r]) => c + "," + r));
|
||||
const seen = new Set([cells[0].join(",")]);
|
||||
const stack = [cells[0]];
|
||||
while (stack.length) {
|
||||
const [c, r] = stack.pop();
|
||||
for (const [nc, nr] of nbrs(c, r)) {
|
||||
if (provOf(at(nc, nr)) === P && !seen.has(nc + "," + nr)) { seen.add(nc + "," + nr); stack.push([nc, nr]); }
|
||||
}
|
||||
}
|
||||
console.log(`${set.size === seen.size ? "OK " : "SPLIT"} ${P}: ${cells.length} hexes${set.size !== seen.size ? " (" + (set.size - seen.size) + " orphan)" : ""}`);
|
||||
}
|
||||
|
||||
// adjacency between provinces
|
||||
const adj = new Set();
|
||||
for (let r = 0; r < H; r++) for (let c = 0; c < rows[r].length; c++) {
|
||||
const p = provOf(rows[r][c]); if (!p) continue;
|
||||
for (const [nc, nr] of nbrs(c, r)) { const q = provOf(at(nc, nr)); if (q && q !== p) adj.add([p, q].sort().join("")); }
|
||||
}
|
||||
console.log("adjacency:", [...adj].sort().join(" "));
|
||||
|
||||
// city checks
|
||||
let bad = 0;
|
||||
for (const [id, name, , prov, c, r] of CITY_DEFS) {
|
||||
const p = provOf(at(c, r));
|
||||
if (p !== prov) { console.log(`CITY BAD: ${name} wants ${prov} but tile(${c},${r})='${at(c, r)}'`); bad++; }
|
||||
}
|
||||
// uniqueness of city tiles
|
||||
const tiles = new Set();
|
||||
for (const [id, name, , , c, r] of CITY_DEFS) { const k = c + "," + r; if (tiles.has(k)) { console.log("CITY OVERLAP at", k); bad++; } tiles.add(k); }
|
||||
console.log(bad ? `${bad} city problems` : `all ${CITY_DEFS.length} cities valid & unique`);
|
||||
|
||||
// river points on land?
|
||||
for (const rv of RIVERS) for (const [c, r] of rv.pts) if (!isLand(at(c, r))) console.log(`river ${rv.name} off-land at ${c},${r}`);
|
||||
|
||||
// world extent
|
||||
let minX = 1e9, maxX = -1e9, minZ = 1e9, maxZ = -1e9;
|
||||
for (let r = 0; r < H; r++) for (let c = 0; c < rows[r].length; c++) if (isLand(rows[r][c])) {
|
||||
const [x, z] = hexToWorld(c, r);
|
||||
minX = Math.min(minX, x); maxX = Math.max(maxX, x); minZ = Math.min(minZ, z); maxZ = Math.max(maxZ, z);
|
||||
}
|
||||
console.log(`map world extent x:[${minX.toFixed(0)},${maxX.toFixed(0)}] z:[${minZ.toFixed(0)},${maxZ.toFixed(0)}]`);
|
||||
@@ -0,0 +1,118 @@
|
||||
// Generates the ASCII hex map by painting province polygons, prints preview.
|
||||
const W = 27, H = 29;
|
||||
const grid = Array.from({ length: H }, () => Array(W).fill("."));
|
||||
|
||||
// point in polygon (hex-center space, col/row floats)
|
||||
function pip(x, y, poly) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const [xi, yi] = poly[i], [xj, yj] = poly[j];
|
||||
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
// painted in order; later paints overwrite
|
||||
const PAINTS = [
|
||||
["g", [[1,10],[8,9],[10,12],[10,15],[9,18],[8,22],[6,26],[3,26],[1,20],[0,14]]],
|
||||
["n", [[6,11],[14,11],[16,13],[16,17],[14,21],[11,22],[8,20],[6,16]]],
|
||||
["h", [[15,11],[21,11],[25,14],[24,19],[19,21],[15,18],[15,13]]],
|
||||
["u", [[11,10],[17,10],[18,13],[16,16],[12,16],[10,13]]],
|
||||
["x", [[15,9],[21,9],[22,12],[19,15],[16,14],[15,11]]],
|
||||
["l", [[0,0],[8,0],[8,4],[6,6],[3,7],[0,6]]],
|
||||
["o", [[4,4],[10,4],[11,7],[10,10],[7,11],[5,9],[3,7]]],
|
||||
["s", [[7,7],[12,7],[13,10],[11,12],[8,12],[6,9]]],
|
||||
["y", [[9,1],[17,1],[18,4],[16,6],[11,6],[9,4]]],
|
||||
["j", [[9,4],[17,5],[17,8],[13,9],[10,7],[9,5]]],
|
||||
["q", [[15,6],[22,6],[23,9],[20,12],[17,11],[15,9]]],
|
||||
["a", [[11,8],[16,8],[17,11],[15,13],[11,13],[10,10]]],
|
||||
];
|
||||
|
||||
for (const [ch, poly] of PAINTS)
|
||||
for (let r = 0; r < H; r++)
|
||||
for (let c = 0; c < W; c++)
|
||||
if (pip(c, r, poly)) grid[r][c] = ch;
|
||||
|
||||
// mountain ranges (uppercase) — hand bands
|
||||
function band(pts, chs) {
|
||||
// mark cells near polyline
|
||||
for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) {
|
||||
if (grid[r][c] === ".") continue;
|
||||
let d = 1e9;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const [x1, y1] = pts[i], [x2, y2] = pts[i + 1];
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const t = Math.max(0, Math.min(1, ((c - x1) * dx + (r - y1) * dy) / (dx * dx + dy * dy)));
|
||||
d = Math.min(d, Math.hypot(c - (x1 + t * dx), r - (y1 + t * dy)));
|
||||
}
|
||||
if (d < 0.75 && chs.includes(grid[r][c])) grid[r][c] = grid[r][c].toUpperCase();
|
||||
}
|
||||
}
|
||||
band([[9,4],[11,5],[11,7]], ["j","o"]); // Taihang
|
||||
band([[6,9],[9,10],[11,11]], ["o","s","g"]); // Qinling west
|
||||
band([[11,12],[13,12],[15,13]], ["s","u","n"]); // Funiu
|
||||
band([[2,12],[4,13],[6,15]], ["g"]); // Daba/Shu west
|
||||
band([[7,17],[8,20],[7,23]], ["g","n"]); // southern highlands
|
||||
band([[1,1],[3,3],[5,5]], ["l"]); // Hexi mountains
|
||||
band([[16,1],[17,3],[16,5]], ["y"]); // Yan mountains
|
||||
band([[19,6],[21,8]], ["q"]); // Shandong hills
|
||||
|
||||
console.log(grid.map((row, r) => String(r).padStart(2) + " " + row.join("")).join("\n"));
|
||||
|
||||
// ---------- bake ----------
|
||||
const CITIES = [
|
||||
["ji","y",12,2],["beiping","y",15,2],
|
||||
["ye","j",12,6],["nanpi","j",15,5],
|
||||
["linzi","q",17,7],["beihai","q",21,8],
|
||||
["puyang","a",12,9],["chenliu","a",14,10],
|
||||
["luoyang","s",9,9],["hongnong","s",8,11],
|
||||
["changan","o",7,8],["anding","o",5,6],
|
||||
["wuwei","l",2,3],["tianshui","l",4,5],
|
||||
["xiaopei","x",16,12],["xiapi","x",19,13],
|
||||
["xuchang","u",14,12],["wan","u",12,14],["runan","u",15,14],
|
||||
["shouchun","h",18,15],["jianye","h",21,14],["lujiang","h",18,17],["wu","h",21,17],
|
||||
["xiangyang","n",10,14],["jiangling","n",11,17],["jiangxia","n",14,15],["changsha","n",11,20],
|
||||
["hanzhong","g",4,11],["chengdu","g",3,15],["zitong","g",3,13],["jiangzhou","g",6,17],["yunnan","g",6,22],
|
||||
];
|
||||
function snap(prov, c, r) {
|
||||
let best = null, bd = 1e9;
|
||||
for (let rr = 0; rr < H; rr++) for (let cc = 0; cc < W; cc++) {
|
||||
if (grid[rr][cc].toLowerCase() !== prov) continue;
|
||||
const d = Math.hypot(cc - c, rr - r);
|
||||
if (d < bd) { bd = d; best = [cc, rr]; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
console.log("\n=== CITY COORDS ===");
|
||||
const used = new Set();
|
||||
for (const [id, p, c, r] of CITIES) {
|
||||
const [cc, rr] = snap(p, c, r);
|
||||
if (!cc && cc !== 0) { console.log(`!! ${id} NO TILE for ${p}`); continue; }
|
||||
if (used.has(cc+","+rr)) console.log(`!! overlap ${id} @${cc},${rr}`);
|
||||
used.add(cc+","+rr);
|
||||
console.log(`["${id}", "${cc}", "${rr}"],`);
|
||||
}
|
||||
const RIVERS = [
|
||||
["Yellow River", [[5,6],[6,7],[7,8],[8,8],[9,9],[10,9],[11,8],[12,7],[13,7],[14,8],[16,8],[18,8],[20,8]]],
|
||||
["Yangtze", [[3,15],[4,15],[6,15],[8,15],[10,15],[12,15],[13,15],[15,15],[17,16],[19,16],[21,16],[23,17]]],
|
||||
["Han", [[9,12],[9,13],[10,14]]],
|
||||
["Huai", [[14,13],[16,13],[18,14],[19,15]]],
|
||||
];
|
||||
console.log("\n=== RIVERS ===");
|
||||
for (const [name, pts] of RIVERS) {
|
||||
const out = pts.map(([c, r]) => {
|
||||
// nearest land tile
|
||||
let best = null, bd = 1e9;
|
||||
for (let rr = 0; rr < H; rr++) for (let cc = 0; cc < W; cc++) {
|
||||
if (grid[rr][cc] === ".") continue;
|
||||
const d = Math.hypot(cc - c, rr - r);
|
||||
if (d < bd) { bd = d; best = [cc, rr]; }
|
||||
}
|
||||
return best;
|
||||
});
|
||||
console.log(`{ name: "${name}", pts: [${out.map(([c,r])=>`[${c},${r}]`).join(",")}] },`);
|
||||
}
|
||||
console.log("\n=== MAP_ROWS ===");
|
||||
console.log("export const MAP_ROWS = [");
|
||||
for (const row of grid) console.log(` "${row.join("")}",`);
|
||||
console.log("];");
|
||||
@@ -0,0 +1,74 @@
|
||||
// Headless smoke test: create a game, simulate many turns.
|
||||
const shim = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: k => shim.get(k) ?? null,
|
||||
setItem: (k, v) => shim.set(k, String(v)),
|
||||
removeItem: k => shim.delete(k),
|
||||
get length() { return shim.size; },
|
||||
key: i => [...shim.keys()][i] ?? null,
|
||||
};
|
||||
|
||||
import { newGame, G, F, saveGame, loadGame, listSaves } from "../js/state.js";
|
||||
import { endTurn, recruitToGarrison } from "../js/sim.js";
|
||||
import { factionTroopCount } from "../js/state.js";
|
||||
import { applyEffect } from "../js/events.js";
|
||||
|
||||
const opts = {
|
||||
mode: "campaign", difficulty: "normal", playerType: "custom",
|
||||
custom: { rulerName: "Minh", color: "#c2452d", emblem: "⚔", origin: "governor", cityId: "xuchang", factionName: "" },
|
||||
};
|
||||
let g = newGame(opts);
|
||||
console.log("factions:", Object.keys(g.factions).length);
|
||||
console.log("generals:", Object.keys(g.generals).length);
|
||||
console.log("cities:", Object.keys(g.cities).length);
|
||||
const pf = F("player");
|
||||
console.log("player cities:", pf.cities, "gold:", pf.gold, "gens:", Object.values(g.generals).filter(x => x.faction === "player").map(x => x.name));
|
||||
|
||||
// sanity: leaders assigned
|
||||
for (const f of Object.values(g.factions)) {
|
||||
if (f.id === "neutral") continue;
|
||||
if (!f.leader) console.log(`!! faction ${f.id} has no leader`);
|
||||
}
|
||||
|
||||
// resolve any pending events each turn by choosing first option
|
||||
function drainEvents() {
|
||||
let guard = 0;
|
||||
while (g.pendingEvents.length && guard++ < 50) {
|
||||
const ev = g.pendingEvents.shift();
|
||||
const choice = ev.choices[0];
|
||||
try { applyEffect(choice.effect); } catch (e) { console.log("EFFECT ERR", ev.title, e.message); }
|
||||
}
|
||||
}
|
||||
|
||||
let battles = 0, errors = [];
|
||||
for (let t = 0; t < 60; t++) {
|
||||
try {
|
||||
// simple player activity: recruit sometimes
|
||||
if (t % 3 === 0) { const c = g.cities[pf.cities[0]]; if (c) recruitToGarrison(c, "spear", Math.min(c.levies, 200)); }
|
||||
endTurn();
|
||||
battles += g.replays.filter(r => r.rounds?.length || r.kind === "surrender").length;
|
||||
drainEvents();
|
||||
} catch (e) {
|
||||
errors.push(`turn ${t}: ${e.stack.split("\n").slice(0, 3).join(" | ")}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
console.log("\nafter 60 turns => year", g.year, "/", g.month);
|
||||
for (const f of Object.values(g.factions)) {
|
||||
if (!f.alive) { console.log(` ✝ ${f.name} destroyed`); continue; }
|
||||
console.log(` ${f.name.padEnd(16)} cities:${String(f.cities.length).padStart(2)} gold:${String(f.gold).padStart(6)} food:${String(f.food).padStart(6)} troops:${factionTroopCount(f.id)} legit:${f.legitimacy} rank:${f.rank}`);
|
||||
}
|
||||
console.log("battles fought:", battles);
|
||||
console.log("journal entries:", g.journal.length);
|
||||
console.log("chronicle:", g.chronicle.slice(0, 6).map(c => `${c.y}/${c.m} ${c.text}`));
|
||||
if (errors.length) { console.log("ERRORS:\n" + errors.join("\n")); process.exit(1); }
|
||||
|
||||
// save/load roundtrip
|
||||
saveGame("test");
|
||||
const saves = listSaves();
|
||||
console.log("saves:", saves.map(s => s.slot));
|
||||
const loaded = loadGame("test");
|
||||
console.log("loaded ok:", loaded.year === g.year && loaded.playerFaction === "player");
|
||||
|
||||
// battle sanity
|
||||
console.log("\nSMOKE TEST PASSED");
|
||||
@@ -0,0 +1,100 @@
|
||||
// Headless stress test: long runs, aggressive player, faction start, save/load.
|
||||
import { newGame, G, F, saveGame, loadGame, listSaves, factionTroopCount } from "../js/state.js";
|
||||
import { endTurn, recruitToGarrison, moveArmy, raiseArmyFromCity } from "../js/sim.js";
|
||||
import { applyEffect } from "../js/events.js";
|
||||
|
||||
// localStorage shim
|
||||
const store = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: k => store.get(k) ?? null,
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: k => store.delete(k),
|
||||
key: i => [...store.keys()][i] ?? null,
|
||||
get length() { return store.size; },
|
||||
};
|
||||
|
||||
function drainEvents(choose = 0) {
|
||||
let guard = 0;
|
||||
while (G.pendingEvents.length && guard++ < 80) {
|
||||
const ev = G.pendingEvents.shift();
|
||||
const choice = ev.choices[Math.min(choose, ev.choices.length - 1)];
|
||||
try { applyEffect(choice.effect); } catch (e) { console.log("EFFECT ERR", ev.title, e.message); }
|
||||
}
|
||||
}
|
||||
|
||||
function run(label, opts, turns, playerBehavior) {
|
||||
newGame(opts);
|
||||
let battles = 0, sieges = 0, errors = [];
|
||||
for (let t = 0; t < turns; t++) {
|
||||
try {
|
||||
playerBehavior?.(t);
|
||||
endTurn();
|
||||
battles += G.replays.filter(r => r.kind === "field").length;
|
||||
sieges += G.replays.filter(r => r.kind === "siege" || r.kind === "surrender").length;
|
||||
drainEvents(t % 4);
|
||||
if (G.gameOver) break;
|
||||
} catch (e) {
|
||||
errors.push(`turn ${t}: ${e.stack.split("\n").slice(0, 4).join(" | ")}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
console.log(`\n== ${label} == turns:${G.turnCount} over:${G.gameOver}(${G.victory ?? "-"}) battles:${battles} sieges:${sieges} errors:${errors.length}`);
|
||||
const top = Object.values(G.factions).filter(f => f.alive && f.id !== "neutral").sort((a, b) => b.cities.length - a.cities.length).slice(0, 5);
|
||||
console.log("top:", top.map(f => `${f.name}:${f.cities.length}`).join(", "));
|
||||
if (errors.length) console.log(errors.join("\n"));
|
||||
return errors;
|
||||
}
|
||||
|
||||
const custom = { mode: "campaign", difficulty: "normal", playerType: "custom", custom: { rulerName: "Minh", color: "#c2452d", emblem: "⚔", origin: "governor", cityId: "xuchang", factionName: "" } };
|
||||
let errs = [];
|
||||
errs += run("passive 150t", custom, 150);
|
||||
|
||||
// aggressive player: raise army & attack neighbors
|
||||
errs += run("aggressive 100t", { ...custom }, 100, (t) => {
|
||||
const pf = F(G.playerFaction);
|
||||
const capCity = G.cities[pf.capital];
|
||||
// keep garrison fed
|
||||
if (capCity && capCity.levies > 300) recruitToGarrison(capCity, "spear", Math.min(capCity.levies, 400));
|
||||
// raise army with best idle general
|
||||
const idle = Object.values(G.generals).filter(g => g.alive && g.faction === pf.id && !g.isLeader &&
|
||||
typeof g.location === "string" && G.cities[g.location]?.owner === pf.id &&
|
||||
!Object.values(G.armies).some(a => a.genId === g.id));
|
||||
if (idle.length && capCity && totalTroopsLocal(capCity.garrison) > 1600) {
|
||||
const troops = {};
|
||||
let rem = Math.floor(totalTroopsLocal(capCity.garrison) * 0.7);
|
||||
for (const tt of ["cav", "bow", "sword", "spear"]) {
|
||||
const take = Math.min(capCity.garrison[tt] || 0, rem); if (take > 0) { troops[tt] = take; rem -= take; }
|
||||
}
|
||||
raiseArmyFromCity(capCity, idle[0].id, troops);
|
||||
}
|
||||
// move armies toward adjacent hostile/neutral provinces
|
||||
for (const army of Object.values(G.armies)) {
|
||||
if (army.faction !== pf.id || army.moved) continue;
|
||||
const nbrs = provNbrsLocal(army.prov);
|
||||
for (const n of nbrs) {
|
||||
const res = moveArmy(army, n, { attackNeutral: true });
|
||||
if (res.ok) break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// faction start
|
||||
errs += run("cao-campaign 120t", { mode: "campaign", difficulty: "hard", playerType: "faction", factionId: "cao" }, 120);
|
||||
|
||||
// challenge
|
||||
errs += run("challenge 12t", { ...custom, mode: "challenge" }, 12);
|
||||
|
||||
// save / load roundtrip mid-game
|
||||
newGame(custom);
|
||||
for (let t = 0; t < 10; t++) { endTurn(); drainEvents(); }
|
||||
saveGame("mid");
|
||||
const ok = loadGame("mid");
|
||||
console.log("\nsave/load roundtrip:", ok ? `ok (year ${ok.year})` : "FAILED");
|
||||
if (!ok) errs.push("save/load failed");
|
||||
|
||||
function totalTroopsLocal(troops) { return Object.values(troops || {}).reduce((s, v) => s + v, 0); }
|
||||
import { PROV_ADJ } from "../js/world.js";
|
||||
function provNbrsLocal(p) { return [...PROV_ADJ[p]]; }
|
||||
|
||||
console.log(errs.length ? "\nSTRESS FAILED" : "\nSTRESS PASSED");
|
||||
process.exit(errs.length ? 1 : 0);
|
||||
Reference in New Issue
Block a user