Creature Collection
hy3 · composer

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 54,540 bytes · SHA-256 3c75aee7f31b
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Verdain — Aether Bindings</title>
<style>
/* ── css: base ── */
:root{
--bg0:#070912; --bg1:#0d1226; --panel:rgba(20,28,54,.66);
--panel-edge:rgba(120,160,255,.28); --ink:#e8eeff; --ink-dim:#9fb0d8;
--accent:#6ad0ff; --accent2:#ff8ad6; --good:#5ef0a8; --bad:#ff6b6b;
--warn:#ffcf6b; --shadow:0 8px 30px rgba(0,0,0,.45);
--radius:16px; --font:'Segoe UI',system-ui,-apple-system,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;width:100%;overflow:hidden;background:var(--bg0);color:var(--ink);font-family:var(--font)}
#game-root{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:radial-gradient(circle at 50% 30%,var(--bg1),var(--bg0) 70%)}
canvas#game{display:block;image-rendering:auto;background:transparent;touch-action:none}
#ui{position:fixed;inset:0;pointer-events:none;z-index:10}
#ui > *{pointer-events:auto}
#vignette{position:fixed;inset:0;pointer-events:none;z-index:30;opacity:0;transition:opacity .6s ease;mix-blend-mode:screen}
#toast{position:fixed;left:50%;bottom:6%;transform:translateX(-50%);z-index:40;
display:flex;flex-direction:column;gap:6px;align-items:center;pointer-events:none}
.toast-line{background:var(--panel);border:1px solid var(--panel-edge);backdrop-filter:blur(10px);
padding:8px 16px;border-radius:12px;font-size:14px;color:var(--ink);box-shadow:var(--shadow);
animation:toastIn .3s ease, toastOut .4s ease 2.2s forwards}
@keyframes toastIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
@keyframes toastOut{to{opacity:0;transform:translateY(-8px)}}
/* ── css: ui ── */
.modal{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:20;padding:20px}
.glass{background:var(--panel);backdrop-filter:blur(14px);border:1px solid var(--panel-edge);
border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;min-width:300px;max-width:560px;
max-height:86vh;overflow:auto;color:var(--ink)}
.glass h2{font-size:22px;margin-bottom:10px;letter-spacing:.5px}
.glass h3{font-size:16px;margin:10px 0 6px;color:var(--accent)}
.dim{color:var(--ink-dim);font-size:13px;line-height:1.5;margin:4px 0}
.btn{background:rgba(106,208,255,.16);border:1px solid var(--panel-edge);color:var(--ink);
padding:10px 14px;border-radius:10px;cursor:pointer;font-size:14px;transition:.15s;margin:4px}
.btn:hover{background:rgba(106,208,255,.32);transform:translateY(-1px)}
.btn.big{padding:14px 22px;font-size:16px;font-weight:600}
.btn.ghost{background:transparent;border-color:rgba(255,255,255,.2)}
.row{display:flex;flex-wrap:wrap;gap:6px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.card{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.12);border-radius:12px;padding:10px}
.barwrap{background:rgba(0,0,0,.4);border-radius:8px;height:12px;overflow:hidden;margin:3px 0}
.bar{height:100%;background:linear-gradient(90deg,var(--good),var(--accent));transition:width .3s}
.bar.low{background:linear-gradient(90deg,var(--bad),var(--warn))}
.battlegrid{position:fixed;left:0;bottom:0;width:100%;display:flex;gap:10px;padding:14px;align-items:flex-end;justify-content:space-between}
.hpbox{background:var(--panel);border:1px solid var(--panel-edge);border-radius:12px;padding:8px 12px;backdrop-filter:blur(8px);min-width:200px}
.logbox{background:var(--panel);border:1px solid var(--panel-edge);border-radius:12px;padding:8px;backdrop-filter:blur(8px);max-width:380px;max-height:120px;overflow:auto;font-size:13px}
.touch{position:fixed;right:16px;bottom:16px;z-index:15;display:grid;grid-template-columns:repeat(3,52px);grid-auto-rows:52px;gap:6px}
.touch .tbtn{background:var(--panel);border:1px solid var(--panel-edge);border-radius:10px;color:var(--ink);font-size:20px;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(8px)}
.title-screen{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;z-index:25;text-align:center}
.title-screen .glow{font-size:46px;font-weight:800;letter-spacing:2px;background:linear-gradient(90deg,#6ad0ff,#ff8ad6,#fff2c4);-webkit-background-clip:text;background-clip:text;color:transparent;animation:pulse 3s ease-in-out infinite}
@keyframes pulse{0%,100%{filter:brightness(1)}50%{filter:brightness(1.4)}}
.bindbar{position:relative;height:34px;background:rgba(0,0,0,.4);border-radius:8px;margin:14px 0;overflow:hidden}
.bindzone{position:absolute;left:42%;width:16%;top:0;bottom:0;background:rgba(94,240,168,.5)}
.bindmarker{position:absolute;top:0;bottom:0;width:6px;background:#fff;box-shadow:0 0 10px #fff}
.mapgrid{display:grid;gap:6px}
.spot{width:90px;height:70px;border-radius:10px;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:12px;border:1px solid rgba(255,255,255,.15)}
.tag{display:inline-block;font-size:11px;padding:2px 8px;border-radius:20px;margin:2px;color:#0a0c1a}
</style>
</head>
<body>
<div id="game-root">
<canvas id="game"></canvas>
<div id="ui"></div>
<div id="vignette"></div>
<div id="toast"></div>
</div>
<script>
'use strict';
// ── const: TYPES ──
const TYPES = ['Verd','Pyra','Aquor','Terra','Volt','Umbra','Lumen'];
// ── function: typeMultiplier ──
// Chart (per spec):
// A=Verd, B=Pyra, C=Aquor, D=Terra, E=Volt, F=Umbra, G=Lumen
// A>B>C>A : Verd>Pyra, Pyra>Aquor, Aquor>Verd
// D>A&C : Terra>Verd,Aquor
// E>D&B : Volt>Terra,Pyra
// F beats all others slightly : Umbra strong vs Verd/Pyra/Aquor/Terra/Volt (=1.5), vs Lumen weak
// G resists everything but weak to F : Lumen resists all (0.5) but loses to Umbra (2)
function typeMultiplier(atk, def){
const A='Verd',B='Pyra',C='Aquor',D='Terra',E='Volt',F='Umbra',G='Lumen';
if(atk===F){
return def===G ? 0.5 : 1.5;
}
if(atk===G){
return 0.5; // resists everything except Umbra
}
if(atk===A) return def===B?2:def===C?0.5:1;
if(atk===B) return def===C?2:def===A?0.5:1;
if(atk===C) return def===A?2:def===B?0.5:1;
if(atk===D) return (def===A||def===C)?2:1;
if(atk===E) return (def===D||def===B)?2:1;
return 1;
}
// ── const: STAT_GROWTH ──
const STAT_GROWTH = (base, level, isHP) => {
// stat growth up to level 50
const lvlScale = Math.pow(level/50, 1.25);
const hpBonus = isHP ? 0.9 : 0.42;
return Math.max(1, Math.floor(base * (0.55 + lvlScale * hpBonus * (isHP?2.2:1)) ));
};
// ── const: LEVEL_CAP ──
const LEVEL_CAP = 50;
// ── const: TEAM_MAX ──
const TEAM_MAX = 6;
// ── const: TOTAL_OBJECTIVE ──
const TOTAL_OBJECTIVE = 8;
// ── const: ANCHORS_OBJECTIVE ──
// unique creatures to collect
const ANCHORS_OBJECTIVE = 4;
// ── function: expForLevel ──
function expForLevel(level){ return Math.floor(8 * Math.pow(level,1.9)); }
// ── function: expReward ──
function expReward(enemyLevel, enemyMaxHP, ourLevel){
const rel = enemyLevel / Math.max(1,ourLevel);
return Math.floor(18 * enemyLevel * (0.5 + rel*0.7));
}
// ── const: TYPE_INFO ──
const TYPE_INFO = {
Verd:{name:'Verd',color:'#5ef0a8',desc:'Living sapience of the overgrowth.'},
Pyra:{name:'Pyra',color:'#ff7a59',desc:'Embered wrath of the cinder deeps.'},
Aquor:{name:'Aquor',color:'#5ab8ff',desc:'Tide-memory of the drowned vales.'},
Terra:{name:'Terra',color:'#c9a05a',desc:'Bedrock will, slow and unbroken.'},
Volt:{name:'Volt',color:'#ffe65a',desc:'Splintered lightning of the storm-choir.'},
Umbra:{name:'Umbra',color:'#b06bff',desc:'Hunger that folds the light away.'},
Lumen:{name:'Lumen',color:'#fff2c4',desc:'Clearest word the aether speaks.'}
};
// ── function: mkAbil ──
function mkAbil(id,name,power,acc,aff,pp,effect,desc){
return {id,name,power:power||0,acc:acc||1,aff,pp,eff:effect||null,desc:desc||''};
}
// ── const: ABIL ──
const ABIL = {
verd_lash:mkAbil('verd_lash','Verdlash',42,0.95,'Verd',25,null,'A whip of living vine.'),
sap_drain:mkAbil('sap_drain','Sapdrain',35,1,'Verd',20,{kind:'drain',value:0.5},'Draws vigour from the foe.'),
bramble_wall:mkAbil('bramble_wall','Bramblewall',0,1,'Verd',15,{kind:'barrier'},'A thorned ward for one blow.'),
bloom_heal:mkAbil('bloom_heal','Bloomtide',0,1,'Verd',12,{kind:'heal',value:0.4},'Mends wounds with pollen.'),
spore_frost:mkAbil('spore_frost','Sporefrost',30,0.9,'Verd',18,{kind:'frost'},'Cold spores slow the foe.'),
thorn_volley:mkAbil('thorn_volley','Thornvolley',18,0.9,'Verd',15,{kind:'multi',hits:3},'Three needle strikes.'),
root_slam:mkAbil('root_slam','Rootslam',60,0.85,'Verd',10,null,'Crushing rootfall.'),
regrow:mkAbil('regrow','Regrowth',0,1,'Verd',10,{kind:'regen'},'Slow mending over turns.'),
quick_sprout:mkAbil('quick_sprout','Sproutstep',25,1,'Verd',20,{kind:'spd',value:1.3},'Boost own speed.'),
verd_judge:mkAbil('verd_judge','Verdjuge',70,0.8,'Verd',8,null,'Verdict of the grove.'),
ember_bite:mkAbil('ember_bite','Emberbite',40,0.95,'Pyra',25,null,'A bite of live coal.'),
scald_ash:mkAbil('scald_ash','Scaldbrand',45,0.9,'Pyra',18,{kind:'burn'},'Sears flesh to ash.'),
coal_burst:mkAbil('coal_burst','Coalburst',20,1,'Pyra',15,{kind:'multi',hits:2},'Two cinder pops.'),
forge_armor:mkAbil('forge_armor','Forgeward',0,1,'Pyra',12,{kind:'barrier'},'Cinder shield for one blow.'),
flare_heal:mkAbil('flare_heal','Flareembrace',0,1,'Pyra',12,{kind:'heal',value:0.35},'Warmth knits the body.'),
pyre_lance:mkAbil('pyre_lance','Pyrelance',65,0.85,'Pyra',10,null,'A lance of white-hot flame.'),
speed_ember:mkAbil('speed_ember','Emberdart',20,1,'Pyra',20,{kind:'spd',value:1.25},'Swift ember skitter.'),
cinder_frost:mkAbil('cinder_frost','Cinderfrost',30,0.9,'Pyra',16,{kind:'frost'},'Odd cold from ash.'),
burn_priority:mkAbil('burn_priority','Firstflame',35,0.95,'Pyra',12,{kind:'priority'},'Strikes before all.'),
inferno:mkAbil('inferno','Infernocall',80,0.75,'Pyra',6,{kind:'burn'},'The great devouring.'),
tide_slap:mkAbil('tide_slap','Tideslap',40,0.95,'Aquor',25,null,'Wet, blunt shock.'),
drown_pull:mkAbil('drown_pull','Drownpull',30,1,'Aquor',18,{kind:'drain',value:0.6},'Drags vigour under.'),
mist_ward:mkAbil('mist_ward','Mistward',0,1,'Aquor',15,{kind:'barrier'},'A veil of cold spray.'),
rain_heal:mkAbil('rain_heal','Rainmend',0,1,'Aquor',12,{kind:'heal',value:0.4},'Falling water soothes.'),
frost_jab:mkAbil('frost_jab','Frostjab',45,0.9,'Aquor',18,{kind:'frost'},'A numbing strike.'),
torrent_volley:mkAbil('torrent_volley','Torrentvolley',16,0.9,'Aquor',15,{kind:'multi',hits:3},'Streaming needles.'),
deep_crush:mkAbil('deep_crush','Deepcrush',62,0.85,'Aquor',10,null,'Pressure of the abyss.'),
regen_tide:mkAbil('regen_tide','Tideroots',0,1,'Aquor',10,{kind:'regen'},'Tide that mends slowly.'),
swift_current:mkAbil('swift_current','Swiftcurrent',25,1,'Aquor',20,{kind:'spd',value:1.3},'Quick water-slide.'),
maelstrom:mkAbil('maelstrom','Maelstromcall',78,0.75,'Aquor',6,{kind:'frost'},'The drowning spiral.'),
stone_fist:mkAbil('stone_fist','Stonefist',42,0.95,'Terra',25,null,'Rocky blunt force.'),
quake_stomp:mkAbil('quake_stomp','Quakestamp',58,0.85,'Terra',12,null,'The ground leaps.'),
bedrock_ward:mkAbil('bedrock_ward','Bedrockward',0,1,'Terra',15,{kind:'barrier'},'Stone shell for one blow.'),
terra_heal:mkAbil('terra_heal','Stonebloom',0,1,'Terra',12,{kind:'heal',value:0.35},'Moss on the stone.'),
dust_frost:mkAbil('dust_frost','Dustfrost',28,0.95,'Terra',18,{kind:'frost'},'Dry chill in the air.'),
gravel_volley:mkAbil('gravel_volley','Gravelvolley',18,0.9,'Terra',15,{kind:'multi',hits:2},'Skittering stones.'),
titan_guard:mkAbil('titan_guard','Titanposture',0,1,'Terra',10,{kind:'selfbuf',value:1.4},'Raises guard (DEF).'),
mountain_fall:mkAbil('mountain_fall','Mountainfall',75,0.8,'Terra',6,null,'A falling plateau.'),
terra_priority:mkAbil('terra_priority','Quickstone',32,0.95,'Terra',12,{kind:'priority'},'Pre-emptive pebble.'),
spark_jab:mkAbil('spark_jab','Sparkjab',40,0.95,'Volt',25,null,'A small discharge.'),
shock_web:mkAbil('shock_web','Shockweb',38,0.9,'Volt',18,{kind:'shock'},'May freeze a turn.'),
thunder_lance:mkAbil('thunder_lance','Thunderlance',62,0.85,'Volt',10,null,'Aimed bolt.'),
static_heal:mkAbil('static_heal','Staticmend',0,1,'Volt',12,{kind:'heal',value:0.35},'Tingle knits flesh.'),
arc_volley:mkAbil('arc_volley','Arcvolley',16,0.9,'Volt',15,{kind:'multi',hits:3},'Arc needles.'),
volt_selfbuff:mkAbil, //removed below
speed_surge:mkAbil('speed_surge','Surge',25,1,'Volt',20,{kind:'spd',value:1.35},'Lightning haste.'),
chain_burn:mkAbil('chain_burn','Chainbrand',34,0.9,'Volt',16,{kind:'burn'},'Burning spark.'),
overload:mkAbil('overload','Overloadcall',80,0.75,'Volt',6,{kind:'shock'},'The breaking grid.'),
umbra_claw:mkAbil('umbra_claw','Umbraclaw',45,0.9,'Umbra',20,null,'A slice of folded night.'),
void_drain:mkAbil('void_drain','Voidfeed',30,1,'Umbra',18,{kind:'drain',value:0.7},'Feasts on life.'),
shadow_frost:mkAbil('shadow_frost','Shadowfrost',32,0.9,'Umbra',16,{kind:'frost'},'Cold from nowhere.'),
umbra_heal:mkAbil('umbra_heal','Umbraembrace',0,1,'Umbra',12,{kind:'heal',value:0.45},'Darkness soothes.'),
gloom_burst:mkAbil('gloom_burst','Gloomburst',22,0.9,'Umbra',15,{kind:'multi',hits:2},'Twin shadow pops.'),
void_priority:mkAbil('void_priority','Voidstep',34,0.95,'Umbra',12,{kind:'priority'},'Strikes from the fold.'),
umbra_ward:mkAbil('umbra_ward','Voidward',0,1,'Umbra',15,{kind:'barrier'},'Night shell for one blow.'),
doom_call:mkAbil('doom_call','Doomcall',82,0.7,'Umbra',5,null,'The closing dark.'),
volt_ward:mkAbil('volt_ward','Voltward',0,1,'Volt',15,{kind:'barrier'},'Coil shield for one blow.'),
lumen_ray:mkAbil('lumen_ray','Lumenray',45,0.9,'Lumen',20,null,'Clean piercing light.'),
radiant_heal:mkAbil('radiant_heal','Radiantmend',0,1,'Lumen',14,{kind:'heal',value:0.5},'Brightest mending.'),
lumen_ward:mkAbil('lumen_ward','Lumenward',0,1,'Lumen',15,{kind:'barrier'},'Glass shell for one blow.'),
lumen_regen:mkAbil('lumen_regen','Lumenroots',0,1,'Lumen',10,{kind:'regen'},'Constant soft light.'),
flash_volley:mkAbil('flash_volley','Flashvolley',18,0.9,'Lumen',15,{kind:'multi',hits:3},'Light needles.'),
lumen_priority:mkAbil('lumen_priority','Firstlight',35,0.95,'Lumen',12,{kind:'priority'},'Beats the dark.'),
judge_light:mkAbil('judge_light','Judgebeam',80,0.75,'Lumen',6,null,'Final bright word.'),
lumen_burn:mkAbil('lumen_burn','Solarbrand',36,0.9,'Lumen',16,{kind:'burn'},'Burning clarity.')
};
// ── const: CREATURES ──
const CREATURES = {
// ---- BASE (18) ----
mosslet:{name:'Mosslet',aff:['Verd'],base:{hp:38,atk:14,def:13,spd:14},lore:'A seed-spirit that hums when rain is near.',learnset:{1:['verd_lash'],5:['sap_drain'],10:['bramble_wall'],16:['spore_frost'],24:['root_slam'],34:['verd_judge'],40:['regrow']},transform:{to:'mosswane',level:36}},
sparkit:{name:'Sparkit',aff:['Volt'],base:{hp:34,atk:16,def:11,spd:18},lore:'Snaps with static when startled; loves high places.',learnset:{1:['spark_jab'],5:['speed_surge'],10:['shock_web'],16:['arc_volley'],24:['thunder_lance'],34:['overload'],40:['chain_burn']},transform:{to:'voltaic',level:36}},
cindle:{name:'Cindle',aff:['Pyra'],base:{hp:36,atk:17,def:12,spd:15},lore:'Carries an ember heart that never cools.',learnset:{1:['ember_bite'],5:['speed_ember'],10:['forge_armor'],16:['scald_ash'],24:['pyre_lance'],34:['inferno'],40:['burn_priority']},transform:{to:'ignar',level:36}},
puddl:{name:'Puddl',aff:['Aquor'],base:{hp:40,atk:14,def:14,spd:13},lore:'A droplet with a fondness for quiet pools.',learnset:{1:['tide_slap'],5:['swift_current'],10:['mist_ward'],16:['drown_pull'],24:['deep_crush'],34:['maelstrom'],40:['regen_tide']},transform:{to:'tidelan',level:36}},
bolder:{name:'Bolder',aff:['Terra'],base:{hp:46,atk:18,def:20,spd:8},lore:'Earthen sentinel that counts every footstep.',learnset:{1:['stone_fist'],5:['titan_guard'],10:['bedrock_ward'],16:['dust_frost'],24:['quake_stomp'],34:['mountain_fall'],40:['terra_priority']},transform:{to:'monolith',level:38}},
gloomling:{name:'Gloomling',aff:['Umbra'],base:{hp:36,atk:17,def:12,spd:16},lore:'Slips between shadow and shape at dusk.',learnset:{1:['umbra_claw'],5:['shadow_frost'],10:['umbra_ward'],16:['void_drain'],24:['gloom_burst'],34:['doom_call'],40:['void_priority']},transform:{to:'nightmaw',level:38}},
lumsprout:{name:'Lumsprout',aff:['Lumen'],base:{hp:35,atk:15,def:14,spd:15},lore:'Holds the first light of an unlit morning.',learnset:{1:['lumen_ray'],5:['lumen_regen'],10:['lumen_ward'],16:['flash_volley'],24:['lumen_priority'],34:['judge_light'],40:['radiant_heal']},transform:{to:'aurora',level:38}},
brine:{name:'Brineback',aff:['Aquor','Terra'],base:{hp:48,atk:16,4:0,def:18,spd:10},lore:'Shelled drifter bearing soil and sea together.',learnset:{1:['tide_slap'],5:['stone_fist'],10:['bedrock_ward'],16:['drown_pull'],24:['gravel_volley'],34:['deep_crush'],42:['mountain_fall']},transform:null},
emberox:{name:'Emberox',aff:['Pyra','Terra'],base:{hp:50,atk:19,def:17,spd:11},lore:'A furnace-beast bedded in warm stone.',learnset:{1:['ember_bite'],5:['stone_fist'],10:['forge_armor'],16:['scald_ash'],24:['quake_stomp'],34:['pyre_lance'],42:['inferno']},transform:null},
wisp:{name:'Galdwisp',aff:['Lumen','Volt'],base:{hp:33,atk:16,def:12,spd:19},lore:'Floating script of a forgotten alphabet.',learnset:{1:['lumen_ray'],5:['spark_jab'],10:['speed_surge'],16:['flash_volley'],24:['lumen_priority'],34:['thunder_lance'],42:['judge_light']},transform:null},
ferntail:{name:'Ferntail',aff:['Verd','Aquor'],base:{hp:42,atk:15,def:15,spd:14},lore:'Trails of fern that drink the marsh.',learnset:{1:['verd_lash'],5:['tide_slap'],10:['mist_ward'],16:['sap_drain'],24:['spore_frost'],34:['torrent_volley'],42:['maelstrom']},transform:null},
ravencoil:{name:'Ravencoil',aff:['Volt','Umbra'],base:{hp:38,atk:18,def:12,spd:17},lore:'A knot of stormwire and swallowed night.',learnset:{1:['spark_jab'],5:['umbra_claw'],10:['shock_web'],16:['void_drain'],24:['arc_volley'],34:['overload'],42:['doom_call']},transform:null},
ashtern:{name:'Ashtern',aff:['Pyra','Umbra'],base:{hp:40,atk:18,def:13,spd:14},lore:'Cinders that remember every flame they fled.',learnset:{1:['ember_bite'],5:['umbra_claw'],10:['forge_armor'],16:['scald_ash'],24:['shadow_frost'],34:['void_drain'],42:['inferno']},transform:null},
frostilla:{name:'Frostilla',aff:['Aquor','Volt'],base:{hp:39,atk:16,def:16,spd:16},lore:'Sheaths itself in thin cold lightning.',learnset:{1:['tide_slap'],5:['spark_jab'],10:['frost_jab'],16:['arc_volley'],24:['shock_web'],34:['deep_crush'],42:['overload']},transform:null},
stonebloom:{name:'Stonebloom',aff:['Verd','Terra'],base:{hp:47,atk:15,def:19,spd:9},lore:'Flower that roots in living cliff-face.',learnset:{1:['verd_lash'],5:['stone_fist'],10:['titan_guard'],16:['regrow'],24:['quake_stomp'],34:['root_slam'],42:['mountain_fall']},transform:null},
sunpup:{name:'Sunpup',aff:['Lumen','Pyra'],base:{hp:37,atk:16,def:13,spd:16},lore:'A bright cub that naps in the noon.',learnset:{1:['lumen_ray'],5:['ember_bite'],10:['lumen_ward'],16:['lumen_burn'],24:['flare_heal'],34:['pyre_lance'],42:['judge_light']},transform:null},
gloomseed:{name:'Gloomseed',aff:['Umbra','Verd'],base:{hp:40,atk:16,def:15,spd:13},lore:'A dark pod that sprouts only in silence.',learnset:{1:['verd_lash'],5:['umbra_claw'],10:['umbra_ward'],16:['sap_drain'],24:['void_drain'],34:['shadow_frost'],42:['doom_call']},transform:null},
dawnmoth:{name:'Dawnmoth',aff:['Lumen','Aquor'],base:{hp:36,atk:15,def:14,spd:18},lore:'Wings dusted with the first colour of day.',learnset:{1:['lumen_ray'],5:['tide_slap'],10:['rain_heal'],16:['flash_volley'],24:['swift_current'],34:['maelstrom'],42:['radiant_heal']},transform:null},
// ---- TRANSFORMED (9) ----
mosswane:{name:'Mosswane',aff:['Verd','Terra'],base:{hp:64,atk:24,def:24,spd:16},lore:'A grove-walker with a stone-green crown.',learnset:{},transform:null},
voltaic:{name:'Voltaic',aff:['Volt','Lumen'],base:{hp:52,atk:26,def:18,spd:30},lore:'A coil of living white-blue current.',learnset:{},transform:null},
ignar:{name:'Ignar',aff:['Pyra','Terra'],base:{hp:62,atk:30,def:22,spd:18},lore:'A walking forge with a molten spine.',learnset:{},transform:null},
tidelan:{name:'Tidelan',aff:['Aquor','Verd'],base:{hp:66,atk:24,def:24,dmglost:0,spd:15},lore:'A tide-spirit that wears the shore as a cloak.',learnset:{},transform:null},
monolith:{name:'Monolith',aff:['Terra','Umbra'],base:{hp:82,atk:30,def:36,spd:10},lore:'A standing mountain that walks when wronged.',learnset:{},transform:null},
nightmaw:{name:'Nightmaw',aff:['Umbra','Volt'],base:{hp:60,atk:32,def:20,spd:26},lore:'The shape that eats the last thought of night.',learnset:{},transform:null},
aurora:{name:'Aurora',aff:['Lumen','Aquor'],base:{hp:58,atk:26,def:24,spd:22},lore:'A curtain of light that learns to think.',learnset:{},transform:null},
luminarch:{name:'Luminarch',aff:['Lumen','Pyra'],base:{hp:64,atk:30,def:26,spd:24},lore:'The bright sovereign of the high air.',learnset:{},transform:null},
voidlord:{name:'Voidlord',aff:['Umbra','Lumen'],base:{hp:78,atk:34,def:28,spd:20},lore:'Thought made of the space between stars.',learnset:{},transform:null}
};
// ── function: fixCreatureStats ──
function fixCreatureStats(){
if(CREATURES.brine.base['4']!==undefined){ CREATURES.brine.base.atk=16; delete CREATURES.brine.base['4']; }
if(CREATURES.tidelan.base.dmglost!==undefined){ delete CREATURES.tidelan.base.dmglost; }
}
// ── const: ZONES ──
const ZONES = [
{id:0,name:'Lumenfield',g0:'#1a2a4a',g1:'#2e4a6e',particle:'light',encRate:0.16,
story:'Where the first binding-light touched the soil, soft creatures learned to gather it.',
guardian:'Tithewarden',gKey:'tidelan'},
{id:1,name:'Cinderwold',g0:'#3a1a14',g1:'#6e3320',particle:'ember',encRate:0.18,
story:'Ash rains soft as snow. The forges below have never gone cold.',
guardian:'Emberox',gKey:'emberox'},
{id:2,name:'Tidemarsh',g0:'#0e2a33',g1:'#13586b',particle:'bubble',encRate:0.19,
story:'The drowned vales remember the shape of every creature that waded them.',
guardian:'Brineback',gKey:'brine'},
{id:3,name:'Stonereach',g0:'#2a241a',g1:'#5a4a2e',particle:'dust',encRate:0.17,
story:'Old roads of bedrock where the slow will of the earth keeps watch.',
guardian:'Monolith',gKey:'monolith'},
{id:4,name:'Stormchoir',g0:'#1a1a3a',g1:'#3e3e8e',particle:'spark',encRate:0.2,
story:'Above the clouds the storm-choir sings a language made only of voltage.',
guardian:'Voltaic',gKey:'voltaic'},
{id:5,name:'Nightfold',g0:'#0d0a1c',g1:'#2a1a4a',particle:'shadow',encRate:0.21,
story:'The place where light is folded and kept. Few return unchanged.',
guardian:'Nightmaw',gKey:'nightmaw'},
];
// ── const: STORY_NODES ──
const STORY_NODES = {
0:{title:'The First Binding',text:'A wandering weaver of light finds you at the edge of Lumenfield. "Take a companion," she says, "and learn to bind the aether before it forgets us."'},
1:{title:'The Ember Pact',text:'The Cinderwold remembers a war of flames. To pass, you must still the forge-beast that guards its heart.'},
2:{title:'The Drowned Vow',text:'Beneath the marsh, a voice offers a bargain: bind the tide, and it will bind you in turn.'},
3:{title:'The Bedrock Oath',text:'Stonereach does not ask. It waits. The Monolith rises only for those who have earned the earth\'s patience.'},
4:{title:'The Voltage Choir',text:'High above, the storm names you aloud. To climb further you must answer in kind.'},
5:{title:'The Folded Light',text:'In Nightfold the last anchor sleeps. Wake it, and the aether will be whole — or unmade.'}
};
// ── const: ANCHOR_BONUS ──
const ANCHOR_BONUS = ['maxHP','atk','def','spd','exp','collection'];
// ── const: ITEMS ──
// Items: collection (3 tiers), consumables, catalysts, field tools
const ITEMS = {
weakbind:{name:'Glimmer Net',tier:1,kind:'bind',desc:'A fragile weave. Bind weak or wounded aether.'},
truebind:{name:'Verdant Lariat',tier:2,kind:'bind',desc:'Braided by the grove. Holds firmer aether.'},
aetherbind:{name:'Aether Crown',tier:3,kind:'bind',desc:'A crown that demands the aether kneel.'},
sapdraught:{name:'Sapdraught',kind:'heal',power:40,desc:'Restores 40 HP.'},
bathewtn:{name:'Bathwyn Tonic',kind:'heal',power:120,desc:'Restores 120 HP.'},
lumenphial:{name:'Lumenphial',kind:'heal',power:9999,desc:'Restores all HP.'},
revivesalt:{name:'Revive Salt',kind:'revive',desc:'Revives a fallen companion at half HP.'},
embercatalyst:{name:'Ember Catalyst',kind:'catalyst',aff:'Pyra',desc:'Forges a flame-bound transform.'},
aquorcatalyst:{name:'Tide Catalyst',kind:'catalyst',aff:'Aquor',desc:'Tempers a sea-bound transform.'},
verdlower:{name:'Word-Lower',kind:'field',desc:'Reveals hidden story nodes nearby.'},
aethermap:{name:'Aether Map',kind:'field',desc:'Marks the next unbound anchor.'}
};
// ── const: BIND_TIER_MULT ──
const BIND_TIER_MULT = [0.55,0.85, 1.25];
// ── let: _uid ──
let _uid = 1;
// ── function: makeCreature ──
function makeCreature(key, level, extras){
const def = CREATURES[key];
const c = {
uid:_uid++, key, name:def.name, aff:def.aff.slice(),
level, exp: extras&&extras.exp!=null?extras.exp:0,
status:{}, buffs:{def:1, spd:1}, barrier:false,
};
computeStats(c);
// moves
const allMoves = collectMoves(def, level);
c.moves = (extras&&extras.moves&&extras.moves.length) ? extras.moves : allMoves.slice(0,4);
if(c.moves.length<1) c.moves.push(['verd_lash',25]);
c.maxHP = c.hp = c.maxhp;
if(extras){
if(extras.hp!=null) c.hp = extras.hp;
if(extras.ivs) c.ivs = extras.ivs;
}
return c;
}
// ── function: collectMoves ──
function collectMoves(def, level){
const set = {};
for(const lvl in def.learnset){
if(+lvl <= level){ def.learnset[lvl].forEach(m=>{ set[m]=1; }); }
}
return Object.keys(set).map(m=>[m, 25]);
}
// ── function: computeStats ──
function computeStats(c){
const def = CREATURES[c.key];
const lv = c.level;
c.maxhp = STAT_GROWTH(def.base.hp, lv, true) + 10;
c.atk = STAT_GROWTH(def.base.atk, lv, false);
c.def = STAT_GROWTH(def.base.def, lv, false);
c.spd = STAT_GROWTH(def.base.spd, lv, false);
c.maxHP = c.maxhp;
}
// ── function: calcDamage ──
function calcDamage(attacker, defender, abil, ctx){
// (0.4 * Level * ATK / DEF * Multiplier) + small random, floored
const defStat = Math.max(1, defender.def * (defender.buffs.def||1));
let mult = typeMultiplier(abil.aff, primAff(defender));
let base = 0.4 * attacker.level * attacker.atk / defStat * mult;
base += Math.random()*3;
const stab = (abil.aff===attacker.aff[0] || abil.aff===attacker.aff[1]) ? 1.25 : 1;
base *= stab;
let crit = (Math.random() < (defender.buffs && 0.08 + attacker.level*0.0008)) ? 1.6 : 1;
base *= crit;
base = Math.max(1, Math.floor(base));
return {dmg:base, mult, crit:crit>1, stab:stab>1};
}
// ── function: primAff ──
function primAff(c){ return c.aff[0]; }
// ── function: applyStatus ──
function applyStatus(target, eff){
if(!eff) return;
if(eff.kind==='burn') target.status.burn = {turns:4, src:'burn'};
else if(eff.kind==='frost') target.status.frost = {turns:3};
else if(eff.kind==='shock'){ if(Math.random()<0.35) target.status.shock = {turns:2}; }
else if(eff.kind==='barrier') target.barrier = true;
else if(eff.kind==='regen') target.status.regen = {turns:4};
else if(eff.kind==='spd') target.buffs.spd = Math.max(target.buffs.spd, eff.value||1.3);
else if(eff.kind==='selfbuff') target.buffs.def = (eff.value||1.4);
else if(eff.kind==='heal'){ target.hp = Math.min(target.maxHP, target.hp + Math.floor(target.maxHP*(eff.value||0.4))); }
else if(eff.kind==='drain'){ /* handled in battle resolver via return value */ }
}
// ── function: tickStatus ──
function tickStatus(c){
const log=[];
if(c.status.burn){ c.hp = Math.max(0, c.hp - Math.max(1,Math.floor(c.maxhp*0.06))); log.push({type:'burn',who:c.uid}); if(--c.status.burn.turns<=0) delete c.status.burn; }
if(c.status.regen){ c.hp = Math.min(c.maxHP, c.hp + Math.floor(c.maxhp*0.12)); log.push({type:'regen',who:c.uid}); if(--c.status.regen.turns<=0) delete c.status.regen; }
if(c.status.frost){ if(--c.status.frost.turns<=0) delete c.status.frost; }
return log;
}
// ── const: SAVE_KEY ──
const SAVE_KEY = 'verdain_save_v1';
// ── function: defaultState ──
function defaultState(){
return {
version:1, zoneId:0, playerX:6, playerY:5,
team:[], bag:{}, discovered:{}, storyStage:0,
anchorsDefeated:0, bonuses:{maxHP:0,atk:0,def:0,spd:0,exp:0,collection:0},
clearedZones:[false,false,false,false,false,false],
postGame:false, muted:false, tutorialDone:false,
seenIntro:false
};
}
// ── function: saveGame ──
function saveGame(){
try{
const s = JSON.parse(JSON.stringify(G));
s.team = G.team.map(serialize);
localStorage.setItem(SAVE_KEY, JSON.stringify(s));
toast('Bound to the aether. Progress saved.');
}catch(e){ toast('Save failed: '+e.message); }
}
// ── function: serialize ──
function serialize(c){
return {key:c.key,name:c.name,aff:c.aff,level:c.level,exp:c.exp,
moves:c.moves.map(m=>m.slice()), hp:c.hp, maxHP:c.maxhp, status:c.status};
}
// ── function: loadGame ──
function loadGame(){
try{
const raw = localStorage.getItem(SAVE_KEY);
if(!raw) return false;
const s = JSON.parse(raw);
Object.assign(G, s);
G.team = s.team.map(d=>makeCreature(d.key, d.level, {moves:d.moves, hp:d.hp, exp:d.exp}));
return true;
}catch(e){ return false; }
}
// ── function: hasSave ──
function hasSave(){ return !!localStorage.getItem(SAVE_KEY); }
// ── const: G ──
const G = defaultState();
// ── function: toast ──
function toast(msg){
const t = document.getElementById('toast');
const d = document.createElement('div');
d.className='toast-line'; d.textContent=msg;
t.appendChild(d);
setTimeout(()=>{ if(d.parentNode) d.parentNode.removeChild(d); }, 2900);
}
// ── function: showVignette ──
function showVignette(color, strength){
const v = document.getElementById('vignette');
v.style.background = 'radial-gradient(circle at 50% 50%, transparent 30%, '+color+' 100%)';
v.style.opacity = strength||0.6;
setTimeout(()=>{ v.style.opacity=0; }, 900);
}
// ── function: clearUI ──
function clearUI(){ document.getElementById('ui').innerHTML=''; }
// ── function: el ──
function el(tag, attrs, html){
const e=document.createElement(tag);
if(attrs) for(const k in attrs){ if(k==='class') e.className=attrs[k]; else if(k==='text') e.textContent=attrs[k]; else e.setAttribute(k,attrs[k]); }
if(html!=null) e.innerHTML=html;
return e;
}
// ── const: W ──
const W = 960;
// ── const: H ──
const H = 600;
// ── let: canvas ──
let canvas = null;
// ── let: ctx ──
let ctx = null;
// ── let: particles ──
let particles = [];
// ── let: floaters ──
let floaters = [];
// ── let: shake ──
let shake = 0;
// ── let: anim ──
let anim = 0;
// ── function: initCanvas ──
function initCanvas(){
canvas=document.getElementById('game');
canvas.width=W; canvas.height=H;
ctx=canvas.getContext('2d');
fitCanvas();
window.addEventListener('resize', fitCanvas);
}
// ── function: fitCanvas ──
function fitCanvas(){
const r=Math.min(window.innerWidth/W, window.innerHeight/H);
canvas.style.width=(W*r)+'px';
canvas.style.height=(H*r)+'px';
}
// ── function: makeParticle ──
function makeParticle(zone){
const z=ZONES[zone]||ZONES[0];
const kinds={light:{c:'#fff2c4',s:1.2},ember:{c:'#ff7a59',s:1.4},bubble:{c:'#9fd8ff',s:1},dust:{c:'#c9a05a',s:0.8},spark:{c:'#ffe65a',s:1.6},shadow:{c:'#b06bff',s:1.2}};
const k=z.particle; const kd=kinds[k]||kinds.light;
return {x:Math.random()*W,y:Math.random()*H,vy:-(0.2+Math.random()*0.6),vx:(Math.random()-0.5)*0.4,c:kd.c,s:kd.s*(0.6+Math.random()), life:1};
}
// ── function: drawBackground ──
function drawBackground(zoneId){
const z=ZONES[zoneId]||ZONES[0];
const g=ctx.createLinearGradient(0,0,0,H);
g.addColorStop(0,z.g0); g.addColorStop(1,z.g1);
ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
for(let i=0;i<4;i++){
const r=120+((anim*8+i*60)%300);
const x=(i*260+ (anim*10)%960)%960, y=H*0.3+Math.sin(anim*0.5+i)*40;
const rg=ctx.createRadialGradient(x,y,0,x,y,r);
rg.addColorStop(0,'rgba(255,255,255,0.05)'); rg.addColorStop(1,'transparent');
ctx.fillStyle=rg; ctx.beginPath(); ctx.arc(x,y,r,0,7); ctx.fill();
}
}
// ── function: updateParticles ──
function updateParticles(zoneId){
for(const p of particles){
p.y+=p.vy; p.x+=p.vx; p.life-=0.004;
if(p.y< -10 || p.life<=0){ Object.assign(p, makeParticle(zoneId)); }
}
}
// ── function: drawParticles ──
function drawParticles(){
for(const p of particles){
ctx.globalAlpha=Math.max(0,p.life)*0.7;
ctx.fillStyle=p.c;
ctx.beginPath(); ctx.arc(p.x,p.y,p.s,0,7); ctx.fill();
}
ctx.globalAlpha=1;
}
// ── function: applyShake ──
function applyShake(){ if(shake>0){ ctx.save(); ctx.translate((Math.random()-0.5)*shake,(Math.random()-0.5)*shake); shake*=0.85; if(shake<0.4) shake=0; } }
// ── function: endShake ──
function endShake(){ if(shake>0) ctx.restore(); }
// ── const: TILE ──
const TILE = 48;
// ── const: COLS ──
const COLS = Math.floor(W/TILE);
// ── const: ROWS ──
const ROWS = Math.floor(H/TILE);
// ── let: mapGrid ──
let mapGrid = [];
// ── let: zoneEntrances ──
let zoneEntrances = {};
// ── function: buildMap ──
function buildMap(zoneId){
mapGrid=[]; zoneEntrances={};
const cols=COLS, rows=ROWS;
for(let y=0;y<rows;y++){ const row=[]; for(let x=0;x<cols;x++){
let t=0;
if(x===0||y===0||x===cols-1||y===rows-1) t=1;
if((x+y)%7===0 && Math.random()<0.25) t=1;
row.push(t);
} mapGrid.push(row); }
const sx=Math.floor(cols/2), sy=Math.floor(rows/2);
mapGrid[sy][sx]=2;
if(zoneId>0){ mapGrid[Math.floor(rows/2)][0]=0; mapGrid[Math.floor(rows/2)][1]=3; zoneEntrances.left=zoneId-1; }
else { mapGrid[Math.floor(rows/2)][0]=0; }
if(G.clearedZones[zoneId] && zoneId<5){ mapGrid[Math.floor(rows/2)][cols-2]=3; mapGrid[Math.floor(rows/2)][cols-1]=0; zoneEntrances.right=zoneId+1; }
return mapGrid;
}
// ── function: tileAt ──
function tileAt(x,y){ if(y<0||y>=ROWS||x<0||x>=COLS) return 1; return mapGrid[y][x]; }
// ── function: moveOnMap ──
function moveOnMap(dx,dy){
if(STATE.mode!=='world') return;
const nx=G.playerX+dx, ny=G.playerY+dy;
const t=tileAt(nx,ny);
if(t===1) return;
G.playerX=nx; G.playerY=ny;
if(t===3){
if(nx<=1 && zoneEntrances.left!=null){ changeZone(zoneEntrances.left); return; }
if(nx>=COLS-2 && zoneEntrances.right!=null){ changeZone(zoneEntrances.right); return; }
}
if(t===2){ openShrine(); return; }
const z=ZONES[G.zoneId];
if(Math.random()<z.encRate){ startWildEncounter(); }
}
// ── function: changeZone ──
function changeZone(id){
G.zoneId=id; G.playerX=Math.floor(COLS/2); G.playerY=Math.floor(rowsFix());
buildMap(id);
particles=spawnParticles(id,60);
showVignette(ZONES[id].g0,0.5);
toast('You enter '+ZONES[id].name+'.');
}
// ── function: rowsFix ──
function rowsFix(){ return ROWS/2; }
// ── const: STATE ──
const STATE = { mode:'title', busy:false, battle:null, menu:null };
// ── function: startWildEncounter ──
function startWildEncounter(){
const z=ZONES[G.zoneId];
const pool=Object.keys(CREATURES).filter(k=>{ const c=CREATURES[k]; return c.transform===null && !c.learnset.locked && Math.random()<1; });
// pick wild species appropriate to zone by affinity loosely
const lv = Math.max(2, Math.floor(3 + G.zoneId*3 + Math.random()*4));
const key = pool[Math.floor(Math.random()*pool.length)];
const wild = makeCreature(key, lv);
G.discovered[key]=true;
beginBattle([wild], false);
}
// ── function: beginBattle ──
function beginBattle(enemies, isGuardian){
STATE.mode='battle';
STATE.battle = {
enemies, turnQueue:[], activeIndex:0, enemyIndex:0,
log:[], phase:'choice', isGuardian:!!isGuardian,
guardianZone: G.zoneId
};
// reset statuses/buffs for fresh battle
G.team.forEach(c=>{ c.status={}; c.buffs={def:1,spd:1}; c.barrier=false; });
enemies.forEach(c=>{ c.status={}; c.buffs={def:1,spd:1}; c.barrier=false; });
showVignette('#000010',0.5);
}
// ── function: openShrine ──
function openShrine(){
STATE.mode='shrine';
renderShrine();
}
// ── function: drawWorld ──
function drawWorld(){
drawBackground(G.zoneId);
// tiles
for(let y=0;y<ROWS;y++){ for(let x=0;x<COLS;x++){
const t=mapGrid[y][x];
const px=x*TILE, py=y*TILE;
if(t===1){ ctx.fillStyle='rgba(10,12,26,0.85)'; ctx.fillRect(px,py,TILE,TILE);
ctx.strokeStyle='rgba(120,160,255,0.15)'; ctx.strokeRect(px+2,py+2,TILE-4,TILE-4); }
else if(t===2){ ctx.fillStyle='#6ad0ff'; ctx.globalAlpha=0.85;
ctx.beginPath(); ctx.arc(px+TILE/2,py+TILE/2,TILE*0.3+Math.sin(anim*2)*3,0,7); ctx.fill();
ctx.globalAlpha=1; ctx.fillStyle='#bff6ff'; ctx.font='10px sans-serif'; ctx.textAlign='center';
ctx.fillText('SHRINE',px+TILE/2,py+TILE-4); }
else if(t===3){ ctx.fillStyle='#5ef0a8'; ctx.globalAlpha=0.4; ctx.fillRect(px,py,TILE,TILE); ctx.globalAlpha=1; }
}}
// player
const px=G.playerX*TILE+TILE/2, py=G.playerY*TILE+TILE/2;
const bob=Math.sin(anim*3)*2;
ctx.save();
const grd=ctx.createRadialGradient(px,py-6+bob,2,px,py-6+bob,18);
grd.addColorStop(0,'#fff2c4'); grd.addColorStop(1,'#ff8ad6');
ctx.fillStyle=grd;
ctx.beginPath(); ctx.arc(px,py-6+bob,12,0,7); ctx.fill();
ctx.fillStyle='#e8eeff'; ctx.fillRect(px-6,py+4+bob,12,10);
ctx.restore();
drawParticles();
// hud strip
drawWorldHUD();
}
// ── function: drawWorldHUD ──
function drawWorldHUD(){
ctx.fillStyle='rgba(10,12,26,0.55)'; ctx.fillRect(0,H-40,W,40);
ctx.fillStyle='#e8eeff'; ctx.font='14px sans-serif'; ctx.textAlign='left';
const anchored=G.anchorsDefeated;
ctx.fillText('Zone: '+ZONES[G.zoneId].name+' Team: '+G.team.length+'/6 Anchors: '+anchored+'/4 Discovered: '+Object.keys(G.discovered).length, 12, H-16);
ctx.textAlign='right';
ctx.fillStyle='#9fb0d8';
ctx.fillText(G.muted?'[M] sound off':'[M] sound on', W-12, H-16);
}
// ── function: activePlayer ──
function activePlayer(){ return G.team[STATE.battle.activeIndex]; }
// ── function: firstAlive ──
function firstAlive(enemies){ return enemies.find(e=>e.hp>0); }
// ── function: buildTurnQueue ──
function buildTurnQueue(){
const b=STATE.battle;
const units=[];
if(activePlayer() && activePlayer().hp>0) units.push({side:'player',c:activePlayer()});
b.enemies.forEach((e,i)=>{ if(e.hp>0) units.push({side:'enemy',c:e,idx:i}); });
// priority moves handled at resolve; base order by spd
units.sort((u1,u2)=> (u2.c.spd*(u2.c.buffs.spd||1)) - (u1.c.spd*(u1.c.buffs.spd||1)));
b.turnQueue=units;
}
// ── function: resolveMove ──
function resolveMove(attacker, defender, abil, side){
const b=STATE.battle;
const log=[]; let dmg=0, info={};
if(abil.power<=0){
applyStatus(attacker, abil.eff);
log.push({text:attacker.name+' uses '+abil.name+'.', type:'info'});
return {log, dmg:0};
}
// accuracy
if(Math.random()>abil.acc){ log.push({text:attacker.name+"'s "+abil.name+' misses!', type:'miss'}); return {log,dmg:0}; }
info=calcDamage(attacker, defender, abil);
let hits = (abil.eff&&abil.eff.kind==='multi') ? abil.eff.hits : 1;
for(let h=0; h<hits; h++){
let d=info.dmg;
if(defender.barrier){ defender.barrier=false; log.push({text:defender.name+' deflects the blow!', type:'barrier'}); d=0; }
else {
defender.hp=Math.max(0, defender.hp-d);
log.push({text:attacker.name+' hits '+defender.name+' for '+d+'!', type:info.crit?'crit':'hit', dmg:d, who:defender.uid, crit:info.crit, mult:info.mult});
if((abil.eff&&abil.eff.kind==='drain')){ const heal=Math.floor(d*(abil.eff.value||0.5)); attacker.hp=Math.min(attacker.maxHP, attacker.hp+heal); log.push({text:attacker.name+' drains '+heal+' HP.', type:'heal', who:attacker.uid}); }
}
if(defender.hp<=0){ log.push({text:defender.name+' is unbound!', type:'faint'}); break; }
}
if(d>0) applyStatus(defender, abil.eff);
if((abil.eff&&abil.eff.kind==='spd')) log.push({text:attacker.name+' quickens.', type:'info'});
if((abil.eff&&abil.eff.kind==='selfbuff')) log.push({text:attacker.name+' raises DEF.', type:'info'});
return {log, dmg};
}
// ── function: enemyAI ──
function enemyAI(enemy){
const usable=enemy.moves.filter(m=>m[1]>0).map(m=>ABIL[m[0]]);
const pick = usable[Math.floor(Math.random()*usable.length)] || ABIL.verd_lash;
// target = first alive player
const target=G.team.find(c=>c.hp>0);
return {abil:pick, target};
}
// ── function: startPlayerTurn ──
function startPlayerTurn(){
const b=STATE.battle;
if(activePlayer().hp<=0){ forceSwitch(); return; }
b.phase='choice';
renderBattleMenu();
}
// ── function: doPlayerMove ──
function doPlayerMove(abilId){
const b=STATE.battle;
if(b.phase!=='choice') return;
const m = activePlayer().moves.find(x=>x[0]===abilId);
if(!m || m[1]<=0){ toast('No energy for that move.'); return; }
m[1]--;
b.pending={side:'player', c:activePlayer(), abil:ABIL[abilId], target:firstAlive(b.enemies)};
builderTurn();
}
// ── function: doSwitch ──
function doSwitch(idx){
const b=STATE.battle;
if(idx===b.activeIndex || G.team[idx].hp<=0){ return; }
b.activeIndex=idx;
b.pending={side:'player', c:activePlayer(), abil:null, target:null, switched:true};
builderTurn();
}
// ── function: builderTurn ──
function builderTurn(){
const b=STATE.battle;
b.phase='resolving';
buildTurnQueue();
runQueue();
}
// ── function: runQueue ──
function runQueue(){
const b=STATE.battle;
if(b.turnQueue.length===0){ afterTurn(); return; }
const u=b.turnQueue.shift();
if(u.c.hp<=0){ runQueue(); return; }
// shock skip
if(u.c.status && u.c.status.shock){ delete u.c.status.shock; toast(u.c.name+' is shocked and loses its turn!'); runQueue(); return; }
playMove(u);
}
// ── function: playMove ──
function playMove(u){
const b=STATE.battle;
if(u.side==='player'){
const def = b.pending.target && b.pending.target.hp>0 ? b.pending.target : firstAlive(b.enemies);
if(!def){ runQueue(); return; }
const res=resolveMove(u.c, def, b.pending.abil, 'player');
res.log.forEach(l=>pushLog(l));
fxFor(res.log);
setTimeout(()=>{ if(def.hp<=0 && allFainted(b.enemies)){ winBattle(); return; } runQueue(); }, 650);
} else {
const ai=enemyAI(u.c);
if(!ai.target){ setTimeout(winBattle,300); return; }
const res=resolveMove(u.c, ai.target, ai.abil, 'enemy');
res.log.forEach(l=>pushLog(l));
fxFor(res.log);
setTimeout(()=>{ if(ai.target.hp<=0 && G.team.every(c=>c.hp<=0)){ loseBattle(); return; } if(activePlayer().hp<=0){ forceSwitch(); return; } runQueue(); }, 650);
}
}
// ── function: afterTurn ──
function afterTurn(){
const b=STATE.battle;
// status ticks
const all=[activePlayer(),...b.enemies].filter(c=>c&&c.hp>0);
all.forEach(c=>tickStatus(c).forEach(l=>pushLog(l)));
if(allFainted(b.enemies)){ winBattle(); return; }
if(G.team.every(c=>c.hp<=0)){ loseBattle(); return; }
startPlayerTurn();
}
// ── function: allFainted ──
function allFainted(list){ return list.every(c=>c.hp<=0); }
// ── function: pushLog ──
function pushLog(entry){
const b=STATE.battle; if(!b) return;
if(typeof entry==='string') b.log.push({text:entry,type:'info'});
else b.log.push(entry);
if(b.log.length>40) b.log.shift();
renderBattleLog();
}
// ── function: fxFor ──
function fxFor(logs){
if(!logs) return;
logs.forEach(l=>{
if(l.type==='hit'||l.type==='crit'){
const c=findCreature(l.who);
const pos=c?creatureScreenPos(c):null;
if(pos){ floaters.push({x:pos.x,y:pos.y,text:''+l.dmg,life:1,crit:l.crit,mult:l.mult});
if(l.crit) shake=Math.max(shake,7);
if(l.mult>=2){ burst(pos.x,pos.y, '#fff2c4'); shake=Math.max(shake,5);} }
} else if(l.type==='heal'){ const c=findCreature(l.who); const pos=c?creatureScreenPos(c):null; if(pos) floaters.push({x:pos.x,y:pos.y,text:'+',life:1,heal:true}); }
});
}
// ── function: findCreature ──
function findCreature(uid){ const all=[activePlayer(),...STATE.battle.enemies]; return all.find(c=>c.uid===uid); }
// ── function: creatureScreenPos ──
function creatureScreenPos(c){
// approximate: enemies on right, player on left
const b=STATE.battle;
if(b.enemies.includes(c)){ const i=b.enemies.indexOf(c); return {x:700, y:200 + i*20}; }
return {x:240, y:380};
}
// ── function: burst ──
function burst(x,y,color){ for(let i=0;i<14;i++){ floaters.push({x,y,vx:(Math.random()-0.5)*4,vy:(Math.random()-0.5)*4,life:1,color, particle:true}); } }
// ── function: forceSwitch ──
function forceSwitch(){
const b=STATE.battle;
if(G.team.some(c=>c.hp>0)){ b.phase='switch'; renderSwitchMenu('Your companion is unbound. Choose another.'); }
else { loseBattle(); }
}
// ── function: winBattle ──
function winBattle(){
const b=STATE.battle;
if(b.isGuardian){
G.clearedZones[b.guardianZone]=true; G.anchorsDefeated++;
const bonus=ANCHOR_BONUS[G.anchorsDefeated%ANCHOR_BONUS.length];
applyAnchorBonus(bonus);
showVignette('#fff2c4',0.7);
setTimeout(()=>showStoryNode(b.guardianZone), 500);
return;
}
// exp
const levSum=b.enemies.reduce((s,e)=>s+e.level,0);
const avg=levSum/b.enemies.length;
G.team.forEach(c=>{ if(c.hp>0) gainExp(c, expReward(avg, 100, c.level)); });
// wild defeated lore complete
endBattle('Victory! The aether settles.');
}
// ── function: applyAnchorBonus ──
function applyAnchorBonus(bonus){
G.bonuses[bonus]=(G.bonuses[bonus]||0)+ (bonus==='exp'||bonus==='collection'?0.1:0.08);
G.team.forEach(c=>applyBonuses(c));
toast('Anchor bound! Permanent '+bonus+' bonus gained.');
if(G.anchorsDefeated>=4 && !G.postGame){
G.postGame=true;
setTimeout(()=>{ showVignette('#b06bff',0.8); toast('The aether is whole. Post-game roam unlocked: fiercer wilds await.'); }, 1200);
}
}
// ── function: applyBonuses ──
function applyBonuses(c){
computeStats(c);
c.maxHP = c.maxhp + Math.floor((G.bonuses.maxHP||0)*c.level*2);
c.atk = Math.floor(c.atk*(1+(G.bonuses.atk||0)));
c.def = Math.floor(c.def*(1+(G.bonuses.def||0)));
c.spd = Math.floor(c.spd*(1+(G.bonuses.spd||0)));
}
// ── function: gainExp ──
function gainExp(c, amt){
amt = Math.floor(amt*(1+(G.bonuses.exp||0)));
c.exp += amt;
let leveled=false;
while(c.level<LEVEL_CAP && c.exp>=expForLevel(c.level+1)){
c.level++; leveled=true;
}
if(leveled){ computeStats(c); relearnIfNeeded(c); c.hp=Math.min(c.maxHP,c.hp+20); toast(c.name+' reached level '+c.level+'!'); }
else computeStats(c);
}
// ── function: relearnIfNeeded ──
function relearnIfNeeded(c){
// ensure moves array kept & learn new if space
const def=CREATURES[c.key];
for(const lvl in def.learnset){
if(+lvl===c.level){ def.learnset[lvl].forEach(m=>{ if(!c.moves.find(x=>x[0]===m)){ if(c.moves.length<4) c.moves.push([m,25]); else toast(c.name+' wants to learn '+ABIL[m].name+' but has 4 moves. Use a creature screen to swap.'); } }); }
}
}
// ── function: loseBattle ──
function loseBattle(){
endBattle('Your companions are unbound. You wake at the last shrine.');
// revive & send to current zone shrine
G.team.forEach((c,i)=>{ c.hp=c.maxHP; c.status={}; c.buffs={def:1,spd:1}; });
G.playerX=Math.floor(COLS/2); G.playerY=Math.floor(ROWS/2);
}
// ── function: endBattle ──
function endBattle(msg){
const b=STATE.battle;
STATE.mode='world';
STATE.battle=null;
G.team.forEach(c=>{ c.barrier=false; });
toast(msg||'Battle ended.');
setTimeout(()=>saveGame(),300);
}
// ── function: bestBindTier ──
function bestBindTier(){
const order=['weakbind','truebind','aetherbind'];
for(const k of order){ if((G.bag[k]||0)>0) return {key:k, tier:ITEMS[k].tier}; }
return null;
}
// ── function: computeCaptureChance ──
function computeCaptureChance(target, tier){
const hpFactor = 1 - (target.hp/target.maxHP);
const base = 0.15 + hpFactor*0.7; // remaining hp lowers chance
const tierMult = BIND_TIER_MULT[tier-1] || 0.55;
const bonus = (G.bonuses.collection||0);
return Math.max(0.03, Math.min(0.97, base*tierMult*(1+bonus)));
}
// ── function: bindAttempt ──
function bindAttempt(target){
const tierInfo=bestBindTier();
if(!tierInfo){ toast('No collection items left!'); return; }
G.bag[tierInfo.key]--;
if(G.bag[tierInfo.key]<=0) delete G.bag[tierInfo.key];
openBindMinigame(target, tierInfo.tier);
}
// ── function: openBindMinigame ──
function openBindMinigame(target, tier){
const base=computeCaptureChance(target, tier);
// rhythm mini-game: a marker sweeps a bar; player taps when in green zone -> boosts
const ui=document.getElementById('ui'); clearUI();
const wrap=el('div',{class:'modal'});
wrap.innerHTML='<div class="glass"><h2>Bind '+target.name+'</h2>'+
'<p class="dim">Tap the pulse when it enters the bright band to strengthen your binding.</p>'+
'<div class="bindbar"><div class="bindzone"></div><div class="bindmarker"></div></div>'+
'<button id="bipt" class="btn big">PULSE</button>'+
'<button id="biskip" class="btn ghost">Skip Rhythm</button>'+
'<div id="bindmsg" class="dim"></div></div>';
ui.appendChild(wrap);
let pos=0, dir=1, speed=0.018+Math.random()*0.01, taps=0, quality=0.5;
let raf;
const marker=wrap.querySelector('.bindmarker');
const zone=wrap.querySelector('.bindzone');
const msg=wrap.querySelector('#bindmsg');
function loop(){ pos+=dir*speed; if(pos>1){pos=1;dir=-1;} if(pos<0){pos=0;dir=1;}
marker.style.left=(pos*100)+'%';
raf=requestAnimationFrame(loop);
}
loop();
function tap(){ taps++; const zoneMin=0.42, zoneMax=0.58;
if(pos>=zoneMin&&pos<=zoneMax){ quality=Math.min(1,quality+0.18); msg.textContent='Perfect pulse! ('+Math.round(quality*100)+'%)'; }
else { quality=Math.max(0.05,quality-0.06); msg.textContent='Off-beat... ('+Math.round(quality*100)+'%)'; }
}
function finish(){ cancelAnimationFrame(raf); wrap.remove(); resolveBind(target, base, quality); }
wrap.querySelector('#bipt').onclick=tap;
wrap.querySelector('#bipt').addEventListener('animationend',()=>{});
wrap.querySelector('#biskip').onclick=finish;
wrap._finish=finish;
// allow Space to pulse
G._bindTap=()=>{ tap(); };
}
// ── function: resolveBind ──
function resolveBind(target, base, quality){
const chance = base * (0.6 + quality*0.8);
if(Math.random()<chance){
target.hp=target.maxHP; // heal when bound
G.team.push(target);
G.discovered[target.key]=true;
toast('Bound! '+target.name+' joins your circle.');
// remove from enemies
STATE.battle.enemies = STATE.battle.enemies.filter(e=>e!==target);
if(allFainted(STATE.battle.enemies)){ winBattle(); return; }
clearUI();
} else {
G._bindTap=null;
toast(target.name+' slipped the binding!');
clearUI();
startPlayerTurn();
}
}
// ── function: drawBattle ──
function drawBattle(){
const b=STATE.battle;
drawBackground(G.zoneId);
// platform
ctx.fillStyle='rgba(10,12,26,0.4)';
ctx.beginPath(); ctx.ellipse(700,250,120,40,0,0,7); ctx.fill();
ctx.beginPath(); ctx.ellipse(240,420,140,46,0,0,7); ctx.fill();
// enemies
b.enemies.forEach((e,i)=>drawCombatant(e, 700, 230 + i*8, false, i));
// player active
const p=activePlayer();
drawCombatant(p, 240, 400, true, 0);
drawParticles();
drawFloaters();
drawBattleBars();
}
// ── function: drawCombatant ──
function drawCombatant(c, x, y, isPlayer, idx){
if(!c) return;
if(c.hp<=0) ctx.globalAlpha=0.3;
const aff=TYPE_INFO[c.aff[0]];
const grd=ctx.createRadialGradient(x,y-10,2,x,y,38);
grd.addColorStop(0,'#ffffff'); grd.addColorStop(0.5, aff.color); grd.addColorStop(1,'rgba(0,0,0,0.2)');
ctx.fillStyle=grd;
ctx.beginPath(); ctx.arc(x,y,30,0,7); ctx.fill();
ctx.globalAlpha=1;
ctx.fillStyle='#e8eeff'; ctx.font='13px sans-serif'; ctx.textAlign='center';
ctx.fillText(c.name, x, y-38);
if(c.status.burn){ ctx.fillStyle='#ff7a59'; ctx.fillText('~burn', x, y+46); }
if(c.status.frost){ ctx.fillStyle='#9fd8ff'; ctx.fillText('~frost', x, y+46); }
if(c.status.regen){ ctx.fillStyle='#5ef0a8'; ctx.fillText('~regen', x, y+46); }
if(c.barrier){ ctx.strokeStyle='#fff2c4'; ctx.lineWidth=3; ctx.beginPath(); ctx.arc(x,y,34,0,7); ctx.stroke(); }
}
// ── function: drawBattleBars ──
function drawBattleBars(){
const b=STATE.battle;
const p=activePlayer();
// only show names; detailed bars in DOM
if(p) drawMiniBar(p, 120, 460);
b.enemies.forEach((e,i)=>drawMiniBar(e, 600, 120+i*30));
}
// ── function: drawMiniBar ──
function drawMiniBar(c, x, y){
ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.fillRect(x,y,200,16);
ctx.fillStyle = c.hp/c.maxHP>0.5?'#5ef0a8':c.hp/c.maxHP>0.2?'#ffcf6b':'#ff6b6b';
ctx.fillRect(x,y,200*(c.hp/c.maxHP),16);
ctx.strokeStyle='rgba(255,255,255,0.3)'; ctx.strokeRect(x,y,200,16);
}
// ── function: drawFloaters ──
function drawFloaters(){
for(const f of floaters){
if(f.particle){ f.x+=f.vx; f.y+=f.vy; f.vy+=0.15; f.life-=0.03; }
else { f.y-=0.6; f.life-=0.02; }
ctx.globalAlpha=Math.max(0,f.life);
if(f.particle){ ctx.fillStyle=f.color; ctx.beginPath(); ctx.arc(f.x,f.y,3,0,7); ctx.fill(); }
else {
ctx.font=(f.crit?'bold 30px':'bold 22px')+' sans-serif'; ctx.textAlign='center';
ctx.fillStyle = f.heal?'#5ef0a8':(f.crit?'#fff2c4':'#ff6b6b');
ctx.fillText((f.crit?'CRIT ':'')+f.text, f.x, f.y);
}
}
ctx.globalAlpha=1;
floaters=floaters.filter(f=>f.life>0);
}
</script>
</body>
</html>