Files
fishing-game/index.html
T

1777 lines
87 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#0d3b66">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Рыбалка</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none}
html,body{width:100%;height:100%;overflow:hidden;background:#0d3b66;font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}
#game{position:fixed;inset:0;touch-action:none;display:block}
button{font-family:inherit;cursor:pointer}
.hidden{display:none!important}
/* ---------- HUD ---------- */
#hud{position:fixed;top:0;left:0;right:0;display:flex;justify-content:space-between;align-items:center;
padding:calc(8px + env(safe-area-inset-top)) 10px 8px;z-index:10;pointer-events:none}
#coins{background:linear-gradient(rgba(9,32,58,.72),rgba(6,25,45,.62));padding:8px 15px;border-radius:999px;color:#ffe9a8;font-weight:700;
font-size:17px;border:1px solid rgba(255,233,168,.22);
box-shadow:0 3px 12px rgba(0,0,0,.35),inset 0 1px 0 rgba(255,255,255,.12);text-shadow:0 1px 2px rgba(0,0,0,.4)}
.hudbtns{display:flex;gap:8px;pointer-events:auto}
.hudbtns button{width:46px;height:46px;font-size:21px;border-radius:14px;
border:1px solid rgba(255,255,255,.16);background:linear-gradient(rgba(9,32,58,.72),rgba(6,25,45,.62));color:#fff;
box-shadow:0 3px 10px rgba(0,0,0,.3),inset 0 1px 0 rgba(255,255,255,.1);
transition:background .15s,transform .12s,box-shadow .12s}
.hudbtns button:hover{background:rgba(30,60,95,.8);transform:translateY(-1px)}
.hudbtns button:active{background:rgba(30,60,95,.85);transform:translateY(1px);box-shadow:0 1px 4px rgba(0,0,0,.3)}
#btnRecall{position:fixed;right:12px;bottom:calc(16px + env(safe-area-inset-bottom));width:54px;height:54px;
border-radius:50%;font-size:24px;background:linear-gradient(rgba(9,32,58,.8),rgba(6,25,45,.7));color:#fff;
border:1px solid rgba(255,255,255,.22);z-index:10;
box-shadow:0 4px 14px rgba(0,0,0,.35),inset 0 1px 0 rgba(255,255,255,.12);
transition:background .15s,transform .12s}
#btnRecall:hover{background:rgba(30,60,95,.85);transform:scale(1.05)}
#btnRecall:active{background:rgba(30,60,95,.9);transform:scale(.96)}
#toast{position:fixed;left:50%;bottom:calc(92px + env(safe-area-inset-bottom));transform:translateX(-50%);
background:linear-gradient(rgba(9,32,58,.94),rgba(5,20,38,.92));color:#fff;padding:11px 20px;border-radius:999px;font-size:15px;font-weight:600;
z-index:30;opacity:0;transition:opacity .25s;pointer-events:none;white-space:nowrap;
border:1px solid rgba(255,255,255,.18);
box-shadow:0 6px 20px rgba(0,0,0,.45),inset 0 1px 0 rgba(255,255,255,.1);
max-width:92vw;overflow:hidden;text-overflow:ellipsis}
/* ---------- Overlays ---------- */
.overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
background:rgba(4,16,32,.62);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);z-index:20;padding:16px}
.panel{position:relative;background:linear-gradient(165deg,#1a4d7d 0%,#123a61 55%,#0d2f4e 100%);
border:1px solid rgba(120,180,235,.35);border-radius:22px;
padding:22px;width:min(92vw,370px);max-height:86vh;overflow-y:auto;color:#eaf4ff;text-align:center;
box-shadow:0 24px 60px rgba(0,0,0,.55),0 4px 14px rgba(0,0,0,.35),inset 0 1px 0 rgba(255,255,255,.14)}
.panel::-webkit-scrollbar{width:8px}
.panel::-webkit-scrollbar-thumb{background:rgba(255,255,255,.18);border-radius:99px}
.panel::-webkit-scrollbar-track{background:transparent}
.title{font-size:34px;font-weight:800;letter-spacing:1px;margin-bottom:10px;
text-shadow:0 2px 10px rgba(0,0,0,.4),0 1px 0 rgba(255,255,255,.15)}
.panel p{font-size:14.5px;line-height:1.5;color:#bcd6ee;margin-bottom:4px}
.big{width:100%;margin-top:16px;padding:15px;font-size:19px;font-weight:800;color:#04263d;
background:linear-gradient(#ffe08a,#f5a623);border:none;border-radius:14px;
box-shadow:0 4px 0 #b97708,0 8px 18px rgba(245,166,35,.35);
transition:transform .12s,box-shadow .12s,filter .12s}
.big:hover{filter:brightness(1.07)}
.big:active{transform:translateY(2px);box-shadow:0 2px 0 #b97708,0 4px 10px rgba(245,166,35,.3)}
.panelhead{font-size:22px;font-weight:800;margin-bottom:4px;text-shadow:0 1px 6px rgba(0,0,0,.35)}
#btnCloseShop{position:absolute;top:12px;right:12px;width:36px;height:36px;border-radius:50%;border:none;
background:rgba(255,255,255,.12);color:#fff;font-size:17px;transition:background .15s,transform .12s}
#btnCloseShop:hover{background:rgba(255,255,255,.22);transform:rotate(90deg)}
#btnCloseShop:active{transform:rotate(90deg) scale(.92)}
.tabs{display:flex;gap:8px;margin:10px 0 6px}
.tabs button{flex:1;padding:11px;border-radius:12px;border:none;background:rgba(255,255,255,.1);
color:#cfe4f7;font-weight:700;font-size:15px;box-shadow:inset 0 1px 0 rgba(255,255,255,.08);
transition:background .15s,color .15s,transform .12s,box-shadow .15s}
.tabs button:hover{background:rgba(255,255,255,.17)}
.tabs button:active{transform:scale(.96)}
.tabs button.active{background:linear-gradient(#ffd76e,#f5a623);color:#04263d;
box-shadow:0 3px 12px rgba(245,166,35,.4),inset 0 1px 0 rgba(255,255,255,.35)}
.section-title{font-size:12px;letter-spacing:1.5px;color:#8fb8dd;text-transform:uppercase;margin:14px 0 8px;text-align:left}
.item{display:flex;align-items:center;justify-content:space-between;gap:10px;background:rgba(255,255,255,.06);
border:1px solid rgba(255,255,255,.09);border-radius:12px;padding:10px 12px;margin-bottom:8px;text-align:left;
transition:background .15s,border-color .15s,transform .12s}
.item:hover{background:rgba(255,255,255,.1);border-color:rgba(255,255,255,.2);transform:translateY(-1px)}
.itemname{font-weight:700;font-size:15px}
.itemdesc{font-size:12px;color:#9fc3e2;margin-top:2px;line-height:1.35}
.buybtn{min-width:96px;padding:10px 10px;border-radius:10px;border:none;font-weight:700;font-size:13px;
background:#2e6da3;color:#fff;white-space:nowrap;flex-shrink:0;
box-shadow:0 2px 6px rgba(0,0,0,.25),inset 0 1px 0 rgba(255,255,255,.15);transition:filter .12s,transform .12s,background .15s}
.buybtn:hover{filter:brightness(1.15)}
.buybtn:active{transform:scale(.96)}
.buybtn.owned{background:rgba(255,255,255,.14)}
.buybtn.owned:hover{filter:none;background:rgba(255,255,255,.22)}
.buybtn.current{background:#2ecc71;color:#04263d;box-shadow:0 2px 8px rgba(46,204,113,.4)}
.buybtn:disabled{opacity:.45;cursor:default}
.buybtn.upgbt{background:#5c55a8}
.rowbtns{display:flex;gap:6px;flex-shrink:0}
.lvtag{color:#ffd75e;font-weight:800;font-size:12px;margin-left:5px;white-space:nowrap}
.item.locked{opacity:.5}
.item.locked:hover{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.09);transform:none}
.statsline{font-size:13px;color:#9fc3e2;margin-bottom:6px}
.jrow{display:flex;align-items:center;gap:10px;padding:9px 10px;background:rgba(255,255,255,.05);
border:1px solid rgba(255,255,255,.06);border-radius:10px;margin-bottom:6px;font-size:14px;text-align:left;
transition:background .15s,border-color .15s}
.jrow:hover{background:rgba(255,255,255,.09);border-color:rgba(255,255,255,.14)}
.jrow .je{font-size:22px;width:30px;text-align:center}
.jrow .jn{flex:1}
.jrow .jc{color:#9fc3e2;font-weight:600;font-size:13px}
.jrow .jsn{color:#ffd75e;font-weight:800;font-size:12px;margin-left:4px}
.jrow.unknown .je,.jrow.unknown .jn{color:#5d7f9e}
.jrow .adesc{font-size:11px;color:#9fc3e2}
.jrow.locked{opacity:.5}
.jrow.got .jc{color:#2ecc71;font-weight:800;font-size:16px}
.jrow.qdone{background:rgba(46,204,113,.14)}
.jrow.qdone .jc{color:#2ecc71;font-weight:700}
.streak{display:flex;gap:6px;justify-content:center;margin:8px 0 4px}
.st-dot{width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;
background:rgba(255,255,255,.08);color:#5d7f9e;border:2px solid rgba(255,255,255,.12);
transition:transform .15s,box-shadow .15s}
.st-dot.on{background:linear-gradient(#ffd76e,#f5a623);color:#04263d;border-color:#ffd75e}
.st-dot.cur{box-shadow:0 0 12px rgba(255,215,94,.85);transform:scale(1.12)}
#card .bigemoji{font-size:74px;margin:4px 0 2px;display:block;animation:bob 1.2s ease-in-out infinite;
filter:drop-shadow(0 6px 10px rgba(0,0,0,.4))}
@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-9px)}}
#card .rarity{display:inline-block;font-weight:800;font-size:12px;letter-spacing:1.5px;text-transform:uppercase;
padding:5px 14px;border-radius:999px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15)}
#card .cname{font-size:24px;font-weight:800;margin:6px 0 6px;text-shadow:0 1px 6px rgba(0,0,0,.4)}
#card .cval{font-size:20px;font-weight:800;color:#ffe9a8;margin-bottom:6px;text-shadow:0 1px 4px rgba(0,0,0,.3)}
#card .csub{font-size:13px;color:#9fc3e2;margin-bottom:4px}
.record{display:inline-block;background:linear-gradient(#ffd76e,#f5a623);color:#04263d;font-weight:800;font-size:14px;letter-spacing:1px;padding:4px 14px;border-radius:999px;animation:recpulse .8s ease-in-out infinite;box-shadow:0 0 14px rgba(255,215,94,.5)}
@keyframes recpulse{0%,100%{transform:scale(1)}50%{transform:scale(1.09)}}
.shiny-badge{display:inline-block;background:linear-gradient(#fff6c9,#ffd75e);color:#6b4a00;font-weight:800;font-size:14px;letter-spacing:1px;padding:4px 14px;border-radius:999px;margin-left:8px;
border:1px solid #ffd75e;box-shadow:0 0 14px rgba(255,215,94,.75);animation:recpulse .8s ease-in-out infinite}
#card.shiny-card .panel{border-color:#ffd75e;box-shadow:0 0 34px rgba(255,215,94,.5),0 10px 40px rgba(0,0,0,.5)}
#card.shiny-card .bigemoji{filter:drop-shadow(0 0 12px rgba(255,215,94,.9))}
.boss-badge{display:inline-block;background:linear-gradient(#fff6c9,#ffd75e);color:#6b4a00;font-weight:800;font-size:14px;letter-spacing:1px;padding:4px 14px;border-radius:999px;margin-left:8px;
border:1px solid #ffd75e;box-shadow:0 0 14px rgba(255,215,94,.75);animation:recpulse .8s ease-in-out infinite}
#card.boss-card .panel{border-color:#ffd75e;box-shadow:0 0 34px rgba(255,215,94,.55),0 10px 40px rgba(0,0,0,.5)}
#card.boss-card .bigemoji{filter:drop-shadow(0 0 12px rgba(255,215,94,.95))}
#card{animation:pop .3s ease}
@keyframes pop{from{transform:scale(.75);opacity:0}to{transform:scale(1);opacity:1}}
.twobtns{display:flex;gap:10px;margin-top:16px}
.twobtns .big{margin-top:0;width:auto;flex:1}
.big.danger{background:linear-gradient(#ff8a7a,#e74c3c);box-shadow:0 4px 0 #96281b,0 8px 18px rgba(231,76,60,.35);color:#fff}
.big.danger:hover{filter:brightness(1.08)}
.big.danger:active{box-shadow:0 2px 0 #96281b,0 4px 10px rgba(231,76,60,.3)}
/* ---------- Адаптив: узкие и низкие экраны ---------- */
@media (max-width:420px){
.title{font-size:27px}
.panel{padding:16px;border-radius:18px}
.panelhead{font-size:19px}
.panel p{font-size:13.5px}
.big{padding:13px;font-size:17px;border-radius:12px}
#card .bigemoji{font-size:58px}
#card .cname{font-size:21px}
.hudbtns button{width:42px;height:42px;font-size:19px;border-radius:12px}
#coins{font-size:15px;padding:7px 12px}
.tabs button{padding:10px 6px;font-size:13.5px}
.jrow .je{font-size:20px;width:26px}
}
@media (max-height:620px){
.panel{max-height:92vh;padding:14px 16px}
.title{font-size:24px;margin-bottom:6px}
.panel p{font-size:13px;line-height:1.4}
#card .bigemoji{font-size:50px;margin:2px 0 0}
.big{margin-top:10px;padding:11px;font-size:16px}
.tabs button{padding:8px}
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="hud">
<div id="coins">🪙 0</div>
<div class="hudbtns">
<button id="btnShop">🛒</button>
<button id="btnReset" title="Сбросить прогресс">🗑️</button>
<button id="btnMute">🔊</button>
</div>
</div>
<button id="btnRecall" class="hidden">↩</button>
<div id="toast"></div>
<!-- Меню -->
<div id="menu" class="overlay">
<div class="panel">
<div class="title">🎣 РЫБАЛКА</div>
<p>👆 <b>Держи палец</b> — заряжай бросок,<br>
<b>отпусти</b> — кидай удочку.<br>
Дальше бросок = реже и жирнее рыба!</p>
<p style="margin-top:8px">Видишь <b style="color:#ff6b5e">!</b> — быстро жми, чтобы зацепить.<br>
При вываживании: тяни, когда рыба отдыхает,<br>
отпусти, когда она <b>тянет</b> — иначе лопнет!<br>
Не тяни слишком долго — рыба <b>сбегает</b> (красная полоса)!</p>
<p style="margin-top:8px">У каждой рыбы свой <b>вес</b>, продажа — по цене за кг.<br>
У удочек есть <b>предел веса</b>: перевесишь — шнур лопнет! <br>
Лучшие снасти = больше шансов на трофей 🏆</p>
<p style="margin-top:8px">Не каждый заброс — с клёвом: если молчит — подожди.<br>
У удилищ есть <b>тормоз (drag)</b>: рывок сильнее — леска скользит!</p>
<button id="btnPlay" class="big">Играть</button>
</div>
</div>
<!-- Магазин -->
<div id="shop" class="overlay hidden">
<div class="panel">
<button id="btnCloseShop">×</button>
<div class="panelhead">🛒 Лавка рыбака</div>
<div class="tabs">
<button id="tabTackle" class="active">Снасти</button>
<button id="tabJournal">Дневник</button>
<button id="tabRewards" title="Награды">🎯</button>
<button id="tabAch" title="Достижения">🏅</button>
</div>
<div id="shopBody"></div>
</div>
</div>
<!-- Подтверждение сброса -->
<div id="resetDlg" class="overlay hidden">
<div class="panel">
<div class="panelhead">🗑️ Сбросить прогресс?</div>
<p style="margin-top:8px">Монеты, снасти и коллекция будут стёрты.<br>Это действие <b>нельзя отменить</b>.</p>
<div class="twobtns">
<button id="btnResetNo" class="big">Отмена</button>
<button id="btnResetYes" class="big danger">Сбросить</button>
</div>
</div>
</div>
<!-- Карточка улова -->
<div id="card" class="overlay hidden">
<div class="panel">
<div class="rarity" id="cardRarity">Обычная</div>
<div><span class="record hidden" id="cardRecord">🏆 РЕКОРД!</span><span class="shiny-badge hidden" id="cardShiny">✨ ШИНИ!</span><span class="boss-badge hidden" id="cardBoss">🏆 БОСС!</span></div>
<span class="bigemoji" id="cardEmoji">🐟</span>
<div class="cname" id="cardName">Карась</div>
<div class="csub" id="cardSub">0.0 кг · 0 🪙/кг</div>
<div class="cval" id="cardVal">+0 🪙</div>
<button id="btnCardOk" class="big">Отлично!</button>
</div>
</div>
<script>
'use strict';
/* =========================================================
ДАННЫЕ
========================================================= */
const SPECIES=[
{id:'carass', name:'Карась', emoji:'🐟', rarity:0, weight:100, minDist:0.00, strength:0.25, wMin:0.1, wMax:0.8, price:20, hue:45},
{id:'perch', name:'Окунь', emoji:'🐠', rarity:0, weight:55, minDist:0.05, strength:0.30, wMin:0.1, wMax:1.5, price:22, hue:120},
{id:'bream', name:'Лещ', emoji:'🐠', rarity:1, weight:26, minDist:0.30, strength:0.40, wMin:0.3, wMax:3.0, price:25, hue:210},
{id:'carp', name:'Карп', emoji:'🐡', rarity:1, weight:22, minDist:0.35, strength:0.45, wMin:0.5, wMax:8.0, price:28, hue:30},
{id:'pike', name:'Щука', emoji:'🦈', rarity:2, weight:9, minDist:0.50, strength:0.60, wMin:1.0, wMax:10.0, price:35, hue:100},
{id:'catfish',name:'Сом', emoji:'🐡', rarity:2, weight:5, minDist:0.65, strength:0.75, wMin:2.0, wMax:20.0, price:45, hue:260},
{id:'sturge', name:'Осётр', emoji:'🐋', rarity:3, weight:2.5, minDist:0.80, strength:0.85, wMin:5.0, wMax:40.0, price:65, hue:200},
{id:'gold', name:'Золотая рыбка', emoji:'🎏', rarity:4, weight:1.2, minDist:0.90, strength:0.90, wMin:0.05, wMax:1.0, price:1500, hue:45},
];
const RARITY_NAMES=['Обычная','Необычная','Редкая','Эпическая','Легендарная'];
const RARITY_COLORS=['#9fc3e2','#2ecc71','#4da3ff','#c56cf0','#ffd75e'];
/* M3: ночная таблица — те же виды, но веса спавна смещены к ночной рыбе (клон SPECIES) */
const SPECIES_NIGHT=SPECIES.map(s=>Object.assign({},s,{weight:({carass:40,perch:80,bream:10,carp:8,pike:22,catfish:18,sturge:4,gold:1.2})[s.id]}));
/* Шини-рыба: шанс золотого улова и множитель его цены (let — автотест форсирует значения) */
let SHINY_CHANCE=0.03, SHINY_MULT=3;
/* M4: босс-рыба (Осётр-босс) — тест-форс появления (let — автотест, как SHINY_CHANCE) */
let forceBoss=false;
/* Спринт 4: пустые клёвы — тест-форс клёва (let — автотест, паттерн forceBoss) */
let forceBite=false;
const RODS=[
{id:0, name:'Дедова удочка', cost:0, maxW:3, line:100, drag:34, reel:1.0, size:0.00, desc:'До 3 кг · Нагрузка 100 · Drag 34 · Подсечка ×1.0'},
{id:1, name:'Стеклопластик', cost:250, maxW:8, line:140, drag:47, reel:1.3, size:0.08, desc:'До 8 кг · Нагрузка 140 · Drag 47 · Подсечка ×1.3'},
{id:2, name:'Карбоновая', cost:700, maxW:18, line:190, drag:63, reel:1.7, size:0.16, desc:'До 18 кг · Нагрузка 190 · Drag 63 · Подсечка ×1.7'},
{id:3, name:'Титан', cost:1600, maxW:40, line:260, drag:87, reel:2.3, size:0.28, desc:'До 40 кг · Нагрузка 260 · Drag 87 · Подсечка ×2.3'},
];
const LURES=[
{id:0, name:'Простой крючок', cost:0, cast:1.00, bite:1.0, rare:0.0, size:0.00, desc:'Надёжно, но рыба не в восторге'},
{id:1, name:'Червь', cost:150, cast:1.00, bite:1.3, rare:0.2, size:0.12, desc:'Клёв на 30% чаще, шанс крупной выше'},
{id:2, name:'Воблер', cost:450, cast:1.15, bite:1.2, rare:0.8, size:0.28, desc:'Дальше бросок, приманивает крупных'},
{id:3, name:'Золотая блёстка',cost:1200, cast:1.20, bite:1.4, rare:2.0, size:0.50, desc:'Лучшее, что есть. К ней плывут легенды'},
];
/* M5: апгрейды снастей — уровни 1..4 (макс), стоимость переходов 1→2, 2→3, 3→4 */
const ROD_UPG_COST=[400,1200,3000];
const LURE_UPG_COST=[300,900,2200];
/* Что даёт следующий уровень (индекс = текущий уровень − 1) — подпись в лавке */
const ROD_UPG_NEXT=['фонарик (ночь), леска ×1.15','тормоз ×1.15, катушка ×1.10','леска ещё ×1.15, крупные +0.10'];
const LURE_UPG_NEXT=['клёв ×1.15','редкие +1.0','крупные +0.15'];
/* =========================================================
СОХРАНЕНИЕ
========================================================= */
const SAVE_KEY='fishing-save-v1';
let save={coins:0,rod:0,lure:0,rods:[0],lures:[0],rodLv:{0:1},lureLv:{0:1},caught:{},muted:false,casts:0,earned:0,
snaps:0,ach:{},quests:null,streak:{last:null,days:0,shieldWeek:null},questsDoneTotal:0,
bossBeaten:false,bossCount:0};
function freshSave(muted){
return {coins:0,rod:0,lure:0,rods:[0],lures:[0],rodLv:{0:1},lureLv:{0:1},caught:{},muted:muted,casts:0,earned:0,
snaps:0,ach:{},quests:null,streak:{last:null,days:0,shieldWeek:null},questsDoneTotal:0,
bossBeaten:false,bossCount:0};
}
function loadSave(){
try{ const s=localStorage.getItem(SAVE_KEY); if(s) Object.assign(save,JSON.parse(s)); }catch(e){}
// миграция старых сейвов: caught[id] был числом → теперь {n, best}
for(const k in save.caught){
if(typeof save.caught[k]==='number') save.caught[k]={n:save.caught[k],best:0};
}
// миграция M1: у записи появился счётчик shiny (sn)
for(const k in save.caught){
const c=save.caught[k];
if(c&&typeof c==='object'&&typeof c.sn!=='number') c.sn=0;
}
// миграция M2: новые поля (старые сейвы обязаны читаться)
if(typeof save.snaps!=='number') save.snaps=0;
if(!save.ach||typeof save.ach!=='object') save.ach={};
if(typeof save.questsDoneTotal!=='number') save.questsDoneTotal=0;
if(!save.quests||typeof save.quests!=='object') save.quests=null;
if(!save.streak||typeof save.streak!=='object') save.streak={last:null,days:0,shieldWeek:null};
// M3: приманки — дефолт для старых сейвов
if(!save.lures||!Array.isArray(save.lures)) save.lures=[0];
if(!save.rods||!Array.isArray(save.rods)) save.rods=[0];
// M5: фонарик больше не приманка (id=4) — убираем из старых сейвов
if(save.lures.includes(4)) save.lures=save.lures.filter(x=>x!==4);
if(!save.lures.length) save.lures=[0];
if(save.lure===4) save.lure=0;
// M5: уровни снастей 1..4 — в старых сейвах их нет: купленным — уровень 1
if(!save.rodLv||typeof save.rodLv!=='object') save.rodLv={};
if(!save.lureLv||typeof save.lureLv!=='object') save.lureLv={};
for(const i of save.rods) if(!(save.rodLv[i]>=1)) save.rodLv[i]=1;
for(const i of save.lures) if(!(save.lureLv[i]>=1)) save.lureLv[i]=1;
delete save.lureLv[4];
// M3: цикл дня/ночи — без dayTime старт с утра; день идёт в реальном времени,
// пока игрока не было, сдвиг догоняет (mod 1)
if(typeof save.dayTime!=='number'||save.dayTime<0) save.dayTime=0;
setDayTime(save.dayTime+(Date.now()-(save.lastSaveMs||Date.now()))/1000/DAY_LENGTH);
save.lastSaveMs=Date.now();
// M4: босс (Осётр-босс) — новые поля, дефолты для старых сейвов
if(typeof save.bossBeaten!=='boolean') save.bossBeaten=false;
if(typeof save.bossCount!=='number') save.bossCount=0;
// M2: стрик входа и квесты дня (новый день — перегенерация)
applyStreak();
refreshQuests();
}
function persist(){
save.dayTime=dayTime; // M3: время суток храним в сейве
save.lastSaveMs=Date.now(); // M3: время последнего сейва (для догона дня)
try{ localStorage.setItem(SAVE_KEY,JSON.stringify(save)); }catch(e){}
}
/* =========================================================
M3: ЦИКЛ ДНЯ/НОЧИ
========================================================= */
const DAY_LENGTH=300; // секунд на полный день
let dayTime=0; // 0..1 — позиция в сутках
let dayPhase='morning'; // morning (0-0.25) day (0.25-0.5) evening (0.5-0.75) night (0.75-1.0)
let nightNoLightToast=false; // тост «Ночью нужен фонарик» — разово, не каждый кадр
function phaseOf(t){ return t<0.25?'morning':(t<0.5?'day':(t<0.75?'evening':'night')); }
function setDayTime(t){ t%=1; if(t<0)t+=1; dayTime=t; dayPhase=phaseOf(dayTime); }
/* =========================================================
M2: НАГРАДЫ — КВЕСТЫ ДНЯ, СТРИК ВХОДА, ДОСТИЖЕНИЯ
========================================================= */
const STREAK_REWARDS=[25,50,75,100,125,150,200]; // награды дней 1-7 стрика
function todayStr(){
const d=new Date();
return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');
}
function dateShift(dateStr,days){
const d=new Date(dateStr+'T00:00:00');
d.setDate(d.getDate()+days);
return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');
}
/* ISO-неделя «YYYY-Www» (детерминированно — тесты повторяют расчёт) */
function isoWeek(dateStr){
const d=new Date(dateStr+'T12:00:00');
const day=(d.getDay()+6)%7;
d.setDate(d.getDate()-day+3); // четверг этой ISO-недели
const year=d.getFullYear();
const jan4=new Date(year,0,4);
const jan4day=(jan4.getDay()+6)%7;
const week1Thu=new Date(year,0,4-jan4day+3);
const week=1+Math.round((d-week1Thu)/(7*24*3600*1000));
return year+'-W'+String(week).padStart(2,'0');
}
/* Детерминированный hash строки даты (FNV-1a) — тесты смогут повторить генерацию */
function daySeed(dateStr){
let h=2166136261>>>0;
for(let i=0;i<dateStr.length;i++){
h^=dateStr.charCodeAt(i);
h=Math.imul(h,16777619);
}
return h>>>0;
}
function seededRand(seed){
let a=seed>>>0;
return function(){
a=(a+0x6D2B79F5)>>>0;
let t=a;
t=Math.imul(t^(t>>>15),t|1);
t^=t+Math.imul(t^(t>>>7),t|61);
return ((t^(t>>>14))>>>0)/4294967296;
};
}
/* --- Квесты дня --- */
const QUEST_NAMES={carass:['карась','карася','карасей'],perch:['окунь','окуня','окуней'],bream:['лещ','леща','лещей']};
const QUEST_TPLS=[
{tpl:'catch_species', make(r){ const sp=SPECIES[[0,1,2][Math.floor(r()*3)]];
return {tpl:'catch_species', param:sp.id, goal:3+Math.floor(r()*3), reward:100+Math.floor(r()*51)}; }},
{tpl:'earn_coins', make(r){
return {tpl:'earn_coins', param:0, goal:[150,250,400][Math.floor(r()*3)], reward:80+Math.floor(r()*41)}; }},
{tpl:'catch_weight', make(r){ const n=[2,3,5][Math.floor(r()*3)];
return {tpl:'catch_weight', param:n, goal:1, reward:120+Math.floor(r()*41)}; }},
{tpl:'casts', make(r){
return {tpl:'casts', param:0, goal:[8,10,15][Math.floor(r()*3)], reward:60+Math.floor(r()*41)}; }},
{tpl:'catch_rarity', make(r){
return {tpl:'catch_rarity', param:[1,2][Math.floor(r()*2)], goal:1, reward:150}; }},
{tpl:'catch_shiny', make(r){
return {tpl:'catch_shiny', param:0, goal:1, reward:200}; }},
];
function pluralN(n,forms){
const m10=n%10,m100=n%100;
if(m10===1&&m100!==11) return forms[0];
if(m10>=2&&m10<=4&&(m100<12||m100>14)) return forms[1];
return forms[2];
}
function questText(q){
switch(q.tpl){
case 'catch_species': return 'Поймай '+q.goal+' '+pluralN(q.goal,QUEST_NAMES[q.param]);
case 'earn_coins': return 'Заработай '+q.goal+' монет за день';
case 'catch_weight': return 'Поймай рыбу от '+q.param+' кг';
case 'casts': return 'Сделай '+q.goal+' забросов за день';
case 'catch_rarity': return 'Поймай рыбу редкости '+RARITY_NAMES[q.param]+'+';
case 'catch_shiny': return 'Поймай shiny-рыбу';
}
return '?';
}
function questIcon(q){
if(q.tpl==='catch_species'){ const sp=SPECIES.find(s=>s.id===q.param); return sp?sp.emoji:'🐟'; }
return {earn_coins:'🪙', catch_weight:'⚖', casts:'🎣', catch_rarity:'⭐', catch_shiny:'✨'}[q.tpl]||'🎯';
}
/* 3 квеста дня из 6 шаблонов, без повторов, детерминированно от даты */
function genQuests(dateStr){
const r=seededRand(daySeed(dateStr));
const ids=QUEST_TPLS.map((t,i)=>i);
for(let i=ids.length-1;i>0;i--){ const j=Math.floor(r()*(i+1)); const tmp=ids[i]; ids[i]=ids[j]; ids[j]=tmp; }
return ids.slice(0,3).map(i=>{ const q=QUEST_TPLS[i].make(r); q.prog=0; q.done=false; return q; });
}
function refreshQuests(){
const today=todayStr();
if(!(save.quests&&save.quests.date===today)){
save.quests={date:today, list:genQuests(today)};
persist();
}
}
/* --- Стрик входа (с еженедельным щитом на один пропуск) --- */
function streakIdx(days){ return ((days-1)%7)+1; }
function nextStreakReward(days){ return STREAK_REWARDS[streakIdx((days||0)+1)-1]; }
function applyStreak(){
const today=todayStr();
if(save.streak.last===today) return;
const fresh=!save.streak.last; // первый визит — стрик запускается (награда дня с неё не выдаётся)
if(save.streak.last===dateShift(today,-1)){
save.streak.days=(save.streak.days||0)+1; // вчерашний визит — стрик растёт
}else if(save.streak.last===dateShift(today,-2)&&save.streak.shieldWeek!==isoWeek(today)){
save.streak.days=(save.streak.days||0)+1; // один пропуск в неделю прощает щит
save.streak.shieldWeek=isoWeek(today);
toast('Щит спас стрик!');
}else{
save.streak.days=1;
}
save.streak.last=today;
if(!fresh){
const coins=STREAK_REWARDS[streakIdx(save.streak.days)-1];
save.coins+=coins;
toast('Стрик: день '+save.streak.days+' — +'+coins+' 🪙');
}
persist();
checkAchievements(); // streak_7
}
/* --- Прогресс квестов (delta — на сколько шагнуть: 1 улов / N монет и т.п.) --- */
function questHit(tpl,condFn,delta){
if(!save.quests) return;
for(const q of save.quests.list){
if(q.done||q.tpl!==tpl) continue;
if(!condFn(q)) continue;
q.prog+=delta||1;
if(q.prog>=q.goal){
q.prog=q.goal; q.done=true;
save.coins+=q.reward; save.questsDoneTotal++;
updateHUD(); // награда может прийти на забросе (вне catchFish) — счётчик монет обновить сразу
sfx('coin');
toast('Квест выполнен: '+questText(q)+' +'+q.reward+' 🪙');
checkAchievements(); // quests_5
}
}
}
/* Новый день: стрик + перегенерация квестов (loadSave, cast, catch) */
function newDayCheck(){ applyStreak(); refreshQuests(); }
/* --- Достижения с постоянными бонусами --- */
function caughtN(s,id){ const c=s.caught&&s.caught[id]; return c?c.n:0; }
function caughtBest(s,id){ const c=s.caught&&s.caught[id]; return c?c.best:0; }
const ACHIEVEMENTS=[
{id:'first_catch', name:'Первый улов', desc:'Поймай первую рыбу', icon:'🐟', bonus:{coinPct:0.01}, check:s=>SPECIES.some(sp=>caughtN(s,sp.id)>0)},
{id:'casts_10', name:'Разминка', desc:'Сделай 10 забросов', icon:'🎣', bonus:{coinPct:0.01}, check:s=>s.casts>=10},
{id:'casts_100', name:'Моряк', desc:'Сделай 100 забросов', icon:'⛵', bonus:{coinPct:0.01}, check:s=>s.casts>=100},
{id:'earned_1000', name:'Первая тысяча', desc:'Заработай 1000 монет', icon:'🪙', bonus:{coinPct:0.01}, check:s=>s.earned>=1000},
{id:'earned_10000', name:'Рыбацкий капитал',desc:'Заработай 10 000 монет', icon:'💰', bonus:{coinPct:0.02}, check:s=>s.earned>=10000},
{id:'species_3', name:'Ботаник', desc:'Поймай 3 вида рыб', icon:'📖', bonus:{coinPct:0.01}, check:s=>SPECIES.filter(sp=>caughtN(s,sp.id)>0).length>=3},
{id:'species_8', name:'Энциклопедия', desc:'Поймай все 8 видов', icon:'🏆', bonus:{coinPct:0.02}, check:s=>SPECIES.every(sp=>caughtN(s,sp.id)>0)},
{id:'rare_fish', name:'Редкий гость', desc:'Поймай рыбу редкости «Редкая»+ ',icon:'⭐', bonus:{coinPct:0.01}, check:s=>SPECIES.some(sp=>sp.rarity>=2&&caughtN(s,sp.id)>0)},
{id:'legendary_fish',name:'Легенда', desc:'Поймай рыбу редкости «Легендарная»',icon:'👑',bonus:{coinPct:0.02}, check:s=>SPECIES.some(sp=>sp.rarity>=4&&caughtN(s,sp.id)>0)},
{id:'first_shiny', name:'Золотая жила', desc:'Поймай shiny-рыбу', icon:'✨', bonus:{coinPct:0.01}, check:s=>SPECIES.some(sp=>{const c=s.caught&&s.caught[sp.id]; return c&&c.sn>0;})},
{id:'weight_10', name:'Трофей', desc:'Поймай рыбу 10+ кг', icon:'⚖', bonus:{windowS:0.05}, check:s=>SPECIES.some(sp=>caughtBest(s,sp.id)>=10)},
{id:'weight_20', name:'Монстр глубин', desc:'Поймай рыбу 20+ кг', icon:'🐋', bonus:{line:5}, check:s=>SPECIES.some(sp=>caughtBest(s,sp.id)>=20)},
{id:'snaps_10', name:'Упёртый', desc:'10 обрывов шнура', icon:'💥', bonus:{coinPct:0.01}, check:s=>s.snaps>=10},
{id:'snaps_50', name:'Хроники обрывов', desc:'50 обрывов шнура', icon:'📜', bonus:{coinPct:0.01}, check:s=>s.snaps>=50},
{id:'streak_7', name:'Семёрка', desc:'Стрик 7 дней', icon:'🔥', bonus:{coinPct:0.01}, check:s=>s.streak&&s.streak.days>=7},
{id:'quests_5', name:'Дневник искателя',desc:'Выполни 5 квестов', icon:'🎯', bonus:{coinPct:0.01}, check:s=>s.questsDoneTotal>=5},
{id:'boss_first', name:'Босс-рыбак', desc:'Победи Осётра-босса', icon:'🏆', bonus:{coinPct:0.03}, check:s=>(s.bossBeaten===true)||(s.bossCount>0)},
];
/* Сумма бонусов по выданным достижениям */
function achBonus(){
const b={coinPct:0,windowS:0,line:0};
for(const a of ACHIEVEMENTS){
if(!save.ach||!save.ach[a.id]) continue;
if(a.bonus.coinPct) b.coinPct+=a.bonus.coinPct;
if(a.bonus.windowS) b.windowS+=a.bonus.windowS;
if(a.bonus.line) b.line+=a.bonus.line;
}
return b;
}
function checkAchievements(){
let changed=false;
for(const a of ACHIEVEMENTS){
if(save.ach[a.id]) continue;
if(a.check(save)){
save.ach[a.id]=true; changed=true;
sfx('coin');
toast('🏅 Достижение: '+a.name);
}
}
if(changed) persist();
}
function bonusText(b){
if(b.coinPct) return '+'+(b.coinPct*100)+'% к монетам';
if(b.windowS) return '+'+b.windowS+' с к подсечке';
if(b.line) return '+'+b.line+' к прочности';
return '';
}
/* =========================================================
ХОЛСТ И РАЗМЕРЫ
========================================================= */
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
let W=0,H=0,DPR=1,waterY=0,boatX=0;
let tip={x:0,y:0};
function resize(){
DPR=Math.min(window.devicePixelRatio||1,2);
W=window.innerWidth; H=window.innerHeight;
canvas.width=W*DPR; canvas.height=H*DPR;
canvas.style.width=W+'px'; canvas.style.height=H+'px';
ctx.setTransform(DPR,0,0,DPR,0,0);
waterY=Math.round(H*0.26);
boatX=Math.round(W*0.20);
initClouds(); initSparkles();
}
window.addEventListener('resize',resize);
/* =========================================================
ЗВУК
========================================================= */
let AC=null;
function ac(){
if(!AC){ try{ AC=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){ return null; } }
if(AC&&AC.state==='suspended'){ AC.resume(); }
return AC;
}
function beep(t0,f,dur,type,vol){
const c=ac(); if(!c)return;
const o=c.createOscillator(),g=c.createGain();
o.type=type||'sine'; o.frequency.setValueAtTime(f,t0);
g.gain.setValueAtTime(vol,t0); g.gain.exponentialRampToValueAtTime(0.001,t0+dur);
o.connect(g); g.connect(c.destination); o.start(t0); o.stop(t0+dur+0.02);
}
function noiseBurst(t0,dur,vol,freq){
const c=ac(); if(!c)return;
const n=Math.floor(c.sampleRate*dur);
const buf=c.createBuffer(1,n,c.sampleRate),d=buf.getChannelData(0);
for(let i=0;i<n;i++) d[i]=(Math.random()*2-1)*(1-i/n);
const src=c.createBufferSource(); src.buffer=buf;
const f=c.createBiquadFilter(); f.type='lowpass'; f.frequency.value=freq;
const g=c.createGain(); g.gain.value=vol;
src.connect(f); f.connect(g); g.connect(c.destination); src.start(t0);
}
function sfx(name){
if(save.muted) return;
const c=ac(); if(!c) return;
const t=c.currentTime;
switch(name){
case 'click': beep(t,500,0.06,'triangle',0.15); break;
case 'cast': noiseBurst(t,0.22,0.15,2500); break;
case 'splash': noiseBurst(t,0.3,0.4,700); break;
case 'bite': beep(t,900,0.07,'square',0.2); beep(t+0.1,1200,0.09,'square',0.2); break;
case 'hook': beep(t,600,0.05,'triangle',0.25); beep(t+0.06,900,0.08,'triangle',0.25); break;
case 'tick': beep(t,260,0.03,'triangle',0.07); break;
case 'pull': beep(t,140,0.18,'sawtooth',0.12); break;
case 'snap': noiseBurst(t,0.25,0.5,3000); beep(t,180,0.2,'sawtooth',0.2); break;
case 'escape': beep(t,500,0.12,'sine',0.2); beep(t+0.12,350,0.16,'sine',0.18); break;
case 'coin': beep(t,1200,0.08,'sine',0.2); beep(t+0.09,1600,0.12,'sine',0.2); break;
case 'catch': [523,659,784,1047].forEach((f,i)=>beep(t+i*0.11,f,0.14,'triangle',0.22)); break;
case 'no': beep(t,220,0.12,'square',0.15); break;
}
}
function vib(p){ try{ if(navigator.vibrate) navigator.vibrate(p); }catch(e){} }
/* =========================================================
СОСТОЯНИЕ ИГРЫ
========================================================= */
let state='MENU'; // MENU IDLE CHARGE CAST WAIT APPROACH BITE REEL SNAP CARD
let uiOpen=true;
let time=0;
let charge=0, power=0;
let castT=0, castFrom={x:0,y:0}, castTo={x:0,y:0};
let bobber={x:0,y:0};
let biteTimer=0, biteT=0, biteFish=null, rippleTimer=0;
let reel=null;
let snapT=0, shake=0;
let stateBoss=false, bossPhase=0; // M4: босс-бой (Осётр-босс, 3 фазы)
let ambient=[]; // фоновые рыбы
let clouds=[], sparkles=[];
let particles=[], ripples=[];
let bobberTrail=[]; // визуал: след поплавка при забросе
/* M5: уровни снастей (1..4) и эффективные статы: базовые + эффекты апгрейдов */
function rodLvOf(i){ const v=save.rodLv&&save.rodLv[i]>=1?save.rodLv[i]:1; return Math.max(1,Math.min(4,v)); }
function lureLvOf(i){ const v=save.lureLv&&save.lureLv[i]>=1?save.lureLv[i]:1; return Math.max(1,Math.min(4,v)); }
function effRod(i){
const b=RODS[i], lv=rodLvOf(i);
const e=Object.assign({},b,{light:lv>=2}); // M5: Lv2 — фонарик (ночная рыбалка)
if(lv>=2) e.line*=1.15; // Lv2: леска крепче
if(lv>=3){ e.drag*=1.15; e.reel*=1.10; } // Lv3: тормоз и катушка
if(lv>=4){ e.line*=1.15; e.size+=0.10; } // Lv4: леска ещё + крупные
return e;
}
function effLure(i){
const b=LURES[i], lv=lureLvOf(i);
const e=Object.assign({},b);
if(lv>=2) e.bite*=1.15; // Lv2: клёв чаще
if(lv>=3) e.rare+=1.0; // Lv3: редкие
if(lv>=4) e.size+=0.15; // Lv4: крупные
return e;
}
const rod=()=>effRod(save.rod);
const lure=()=>effLure(save.lure);
/* M5: фонарик — не приманка, а апгрейд 1 любого удилища (2+ ур.) */
const hasLight=()=>rodLvOf(save.rod)>=2;
const rand=(a,b)=>a+Math.random()*(b-a);
function initFish(){
ambient=[];
for(let i=0;i<9;i++){
ambient.push({
x:Math.random()*W, depth:rand(0.12,0.92), size:rand(8,22),
speed:rand(10,32), dir:Math.random()<0.5?1:-1, hue:rand(140,260), ph:rand(0,6.28)
});
}
}
function initClouds(){
clouds=[];
for(let i=0;i<4;i++){
clouds.push({x:Math.random()*W,y:rand(14,Math.max(20,waterY-50)),s:rand(0.6,1.2),v:rand(4,9)});
}
}
function initSparkles(){
sparkles=[];
for(let i=0;i<24;i++){
sparkles.push({x:Math.random()*W,y:waterY+rand(10,H-waterY-10),ph:rand(0,6.28)});
}
initStars();
}
/* Визуал: звёзды для ночного неба (только отрисовка, на механику не влияют) */
let stars=[];
function initStars(){
stars=[];
for(let i=0;i<46;i++){
stars.push({x:Math.random()*W,y:Math.random()*Math.max(10,waterY-14),r:rand(0.6,1.7),ph:rand(0,6.28),sp:rand(0.8,2.2)});
}
}
/* =========================================================
УТИЛИТЫ
========================================================= */
const $=id=>document.getElementById(id);
let toastTO=null;
function toast(msg){
const t=$('toast'); t.textContent=msg; t.style.opacity=1;
clearTimeout(toastTO); toastTO=setTimeout(()=>t.style.opacity=0,1600);
}
function updateHUD(){ $('coins').textContent='🪙 '+save.coins; $('btnMute').textContent=save.muted?'🔇':'🔊'; }
function splash(x,y,n,spread){
for(let i=0;i<n;i++){
particles.push({
x:x,y:y, vx:rand(-1,1)*(spread||90), vy:rand(-160,-40),
g:320, life:rand(0.35,0.7), max:0.7, size:rand(1.5,3.5),
color:Math.random()<0.5?'255,255,255':'160,215,255'
});
}
}
function addRipple(x,y,max){ ripples.push({x:x,y:y,r:4,max:max||36,a:0.6}); }
/* =========================================================
ЛОГИКА
========================================================= */
function pickSpecies(dist){
const l=lure();
// M3: ночь/вечер — ночная таблица, иначе дневная (жёсткий переключатель)
const tbl=(dayPhase==='night'||dayPhase==='evening')?SPECIES_NIGHT:SPECIES;
let total=0;
const ws=tbl.map(s=>{
let w=s.weight;
if(dist<s.minDist) w*=0.06;
w*=1+l.rare*s.rarity;
total+=w; return w;
});
let r=Math.random()*total;
for(let i=0;i<tbl.length;i++){ r-=ws[i]; if(r<=0) return tbl[i]; }
return tbl[0];
}
function biteWindow(sp){
if(sp.boss) return rand(0.25,0.5); // M4: босс клюёт реже — окно подсечки короче
return Math.max(0.35, 0.8 - sp.rarity*0.08 + save.rod*0.04 + achBonus().windowS);
}
/* Спринт 4: шанс клёва на таймер — не каждый заброс даёт рыбу (дальше = чаще, ночь тише) */
function biteChance(){
const dist=Math.min(1,(castTo.x-(boatX+30))/Math.max(1,W-boatX-70));
let p=0.65+0.25*dist;
if(dayPhase==='night') p*=0.85;
return p;
}
/* Ролл веса рыбы: сдвиг в крупную сторону зависит от удочки и приманки */
function rollWeight(sp){
const B=Math.min(0.95, rod().size + lure().size);
const s=Math.pow(Math.random(), 2/(1+2*B)); // B=0 → смещено к мелкой, B↑ → ближе к равномерной
return sp.wMin + s*(sp.wMax-sp.wMin);
}
function startBite(){
const dist=Math.min(1,(castTo.x-(boatX+30))/Math.max(1,W-boatX-70));
let sp=pickSpecies(dist);
// M4: осётр → босс (30% шанс или тест-форс) — клонируем, чтобы не трогать таблицу SPECIES
if(sp.id==='sturge' && (forceBoss || Math.random()<0.3)) sp=Object.assign({},sp,{boss:true});
const fromLeft=Math.random()<0.5;
const d=Math.max(0.25,sp.minDist)*0.8+0.1;
biteFish={
x:fromLeft?-30:W+30, y:waterY+d*(H-waterY-40),
sx:fromLeft?-30:W+30, sy:waterY+d*(H-waterY-40),
dir:fromLeft?1:-1, sp:sp, t:0, mode:'in'
};
state='APPROACH';
}
function hook(){
const sp=biteFish.sp;
biteFish=null;
sfx('hook'); vib(30);
state='REEL';
stateBoss=!!sp.boss; bossPhase=sp.boss?1:0; // M4: старт босс-боя — фаза 1
const w=rollWeight(sp);
const ov=w/rod().maxW; // 1.0 = рыба ровно на пределе удочки
const str=sp.boss?0.95:sp.strength; // M4: босс сильнее (осётр 0.85 → 0.95)
const aggr=Math.min(1, 0.35*str + 0.75*Math.min(1.25,ov)/1.25); // «злость» рыбы
const d0=45+7*w; // чем крупнее, тем дальше от лодки (T2b: буфер для «сдавать в рывок»)
const line=rod().line+achBonus().line; // прочность с бонусом достижений
// Спринт 4: выносливость — крупная рыба выносливее, босс свежий
const st0=sp.boss?1:0.65+0.35*Math.min(1,w/sp.wMax);
reel={d:d0, d0:d0, tension:0, escape:0, holding:false, phase:'calm', phaseT:rand(0.8,1.5), sp:sp, w:w, ov:ov, aggr:aggr, tick:0, line:line, boss:!!sp.boss, st:st0, tired:false};
}
/* Спринт 4: сила рыбы (перетягивание с drag) — глобальная, автотесты вызывают */
function fishForce(r,ag,pull){
const base=pull?(16+34*r.st)*(0.5+0.9*ag)*(0.7+0.5*Math.min(1.5,r.ov)):5+7*ag;
return r.tired?0.45*base:base;
}
function doCast(){
const p=Math.max(0.12,power);
const dist=Math.min(0.97,(0.10+0.80*p)*lure().cast);
castFrom={x:tip.x,y:tip.y};
castTo={x:boatX+30+dist*Math.max(1,W-boatX-70), y:waterY-2};
castT=0; state='CAST'; sfx('cast');
save.casts++;
newDayCheck();
questHit('casts',()=>true); // condFn обязателен: без него questHit падает с TypeError
checkAchievements();
persist();
}
function recall(){
if(state==='WAIT'||state==='APPROACH'||state==='BITE'){
state='IDLE'; biteFish=null; sfx('click');
}
}
function updateReel(dt){
const r=rod(), sp=reel.sp, ag=reel.aggr, ov=reel.ov;
reel.phaseT-=dt;
if(reel.phaseT<=0){
if(reel.phase==='calm'){
// Спринт 4: рывок длиннее у свежей рыбы, короче у уставшей
reel.phase='pull'; reel.phaseT=(0.8+2.2*reel.st)*(0.6+0.7*ag)*rand(0.8,1.25);
sfx('pull'); vib(40);
}else{
// Спринт 4: пауза растёт по мере усталости
reel.phase='calm'; reel.phaseT=Math.max(0.35,(1.3-0.8*reel.st)*rand(0.8,1.3)); vib(15);
}
}
const pulling=reel.phase==='pull';
// Спринт 4 T2b: выносливость — медленная дуга: свежая тянет сильно, уставшая слабо (Monte-Carlo)
const drain=(pulling?0.016+0.02*ag:0.002)+(reel.holding?0.006:0.001)+(pulling&&!reel.holding?0.01:0);
reel.st=Math.max(0,reel.st-drain*dt);
if(reel.st<=0.12) reel.tired=true; // устала: рывки слабее, втягивается быстрее
const F=fishForce(reel,ag,pulling);
if(reel.holding){
// Спринт 4: перетягивание через drag — рывок сильнее тормоза: леска скользит
const over=F-r.drag;
reel.tension+=(10+Math.max(0,over)*1.1+22*ag*(pulling?1:0.35))*dt;
if(over>0) reel.d+=over*0.22*dt;
else reel.d-=(11+3*r.reel)*(1+0.6*(1-reel.st))*dt; // втягивание, усталую быстрее (до ×1.6)
reel.tick-=dt;
if(reel.tick<=0){ sfx('tick'); reel.tick=0.12; }
if(Math.random()<0.3) splash(castTo.x,waterY,1,40);
}else{
reel.tension-=38*dt;
if(pulling) reel.d+=F*0.32*dt; // свободный бег: рыба реально отходит (стоимость отпускания — дистанция)
}
reel.tension=Math.max(0,reel.tension);
// escape T2b: отпускать в РЫВОК безопасно (бар не растёт) — «сдался рыбе»;
// а вот зависнуть без удержания в затишье = рыба уходит (пассив = сход)
if(reel.holding){
reel.escape=Math.max(0,reel.escape-0.5*dt);
}else if(!pulling){
reel.escape=Math.min(1,reel.escape+(0.2+ag*0.4)*dt);
}
if(reel.escape>=1){
stateBoss=false; bossPhase=0;
const nm=Math.floor(reel.w*sp.price*0.1);
if(nm>0){
save.coins+=nm; persist(); updateHUD();
toast('Сбежала! '+sp.emoji+' '+sp.name+' '+reel.w.toFixed(1)+' кг · +'+nm+' 🪙');
}else{
toast('Сбежала! '+sp.emoji+' '+sp.name+' '+reel.w.toFixed(1)+' кг');
}
state='WAIT'; biteTimer=rand(5,10)/lure().bite;
sfx('escape');
return;
}
if(reel.tension>=reel.line){
state='SNAP'; snapT=1.0; shake=0.5;
save.snaps++; persist(); // счётчик обрывов (достижения)
sfx('snap'); vib(200);
stateBoss=false; bossPhase=0; // M4: настоящий обрыв — босс-бой окончен
toast(ov>1.15?'💥 Рыба крупнее предела удочки!':'💥 Линия лопнула!');
checkAchievements();
}else if(reel.d<=0){
if(reel.boss && bossPhase<3){
// M4: босс-обрыв фаз 1-2 — без штрафа (без save.snaps++), дальше фаза глубже
state='SNAP'; snapT=0.6; shake=0.5;
sfx('snap'); vib(120);
toast('⚠️ Босс рвёт леску! Фаза '+bossPhase+'/3');
}else{
catchFish(sp);
}
}else if(reel.d>=reel.d0*1.45){
// near-miss T2b: порог 1.45·d0 — рыба сбежала, утешение 10% цены (SNAP выплаты не даёт)
stateBoss=false; bossPhase=0; // M4: босс тоже может уйти — босс-бой окончен
const nm=Math.floor(reel.w*sp.price*0.1);
if(nm>0){
save.coins+=nm; persist(); updateHUD();
toast('Сбежала! '+sp.emoji+' '+sp.name+' '+reel.w.toFixed(1)+' кг · +'+nm+' 🪙');
}else{
toast('Сбежала! '+sp.emoji+' '+sp.name+' '+reel.w.toFixed(1)+' кг');
}
state='WAIT'; biteTimer=rand(5,10)/lure().bite;
sfx('escape');
}
}
function catchFish(sp){
const w=reel.w;
const boss=!!reel.boss; // M4: пойман Осётр-босс
const shiny=Math.random()<SHINY_CHANCE; // ролл золотого (шини) улова
const b=achBonus();
const mult=(shiny?SHINY_MULT:1)*(boss?5:1); // M4: босс = ×5 к цене
const val=Math.max(1,Math.round(w*sp.price*mult*(0.95+Math.random()*0.1)*(1+b.coinPct)));
save.coins+=val; save.earned+=val;
const prev=save.caught[sp.id]||{n:0,best:0,sn:0};
const isRecord=w>prev.best;
save.caught[sp.id]={n:prev.n+1, best:Math.max(prev.best,w), sn:(prev.sn||0)+(shiny?1:0)};
if(boss){ // M4: босс побеждён
save.bossBeaten=true; save.bossCount++;
toast('🏆 БОСС ПОБЕЖДЁН! +'+val+' 🪙');
}
newDayCheck();
questHit('catch_species',q=>sp.id===q.param);
questHit('catch_weight',q=>w>=q.param);
questHit('catch_rarity',q=>sp.rarity>=q.param);
questHit('catch_shiny',q=>shiny);
questHit('earn_coins',()=>true,val); // прогресс — монеты улова (с бонусами)
checkAchievements();
persist(); updateHUD();
sfx('catch'); vib([40,60,40]);
splash(castTo.x,waterY,boss?40:26,boss?180:140);
// шини — ярче: больше и разноцветнее золотых частиц; босс — частицы крупнее
for(let i=0;i<(boss?44:(shiny?28:10));i++){
particles.push({x:castTo.x,y:waterY,vx:rand(-80,80),vy:rand(-240,-80),g:300,
life:rand(0.5,0.9),max:0.9,size:boss?rand(3,7):rand(2,4),
color:(shiny&&i%3===0)?'255,246,190':'255,215,94'});
}
reel=null; stateBoss=false; bossPhase=0; state='CARD';
showCard(sp,w,val,isRecord,shiny,boss);
}
function updateGame(dt){
// фоновые рыбы
for(const f of ambient){
f.x+=f.dir*f.speed*dt;
if(f.dir>0&&f.x>W+40){ f.x=-40; f.depth=rand(0.12,0.92); }
if(f.dir<0&&f.x<-40){ f.x=W+40; f.depth=rand(0.12,0.92); }
}
switch(state){
case 'CHARGE':
charge+=dt;
power=1-Math.abs(1-((charge/0.9)%2));
break;
case 'CAST':{
castT+=dt/0.65;
if(castT>=1){
castT=1;
bobber={x:castTo.x,y:waterY-2};
state='WAIT';
biteTimer=rand(4,10)/lure().bite;
nightNoLightToast=false; // новый заброс — тост о фонарике может показаться снова
splash(castTo.x,waterY,14,100);
addRipple(castTo.x,waterY,40);
sfx('splash'); vib(10);
}else{
const t=castT;
bobber.x=castFrom.x+(castTo.x-castFrom.x)*t;
bobber.y=castFrom.y+(castTo.y-castFrom.y)*t-150*Math.sin(Math.PI*t);
}
break;
}
case 'WAIT':{
bobber.y=waterY-3+Math.sin(time*2)*1.5;
rippleTimer-=dt;
if(rippleTimer<=0){ addRipple(bobber.x,waterY+3,22); rippleTimer=1.6; }
if(biteFish&&biteFish.mode==='out') updateBiteFishOut(dt);
biteTimer-=dt;
if(biteTimer<=0&&!biteFish){
if(dayPhase==='night'&&!hasLight()){
// M5: ночью без фонарика клёва нет — таймер тикает вхолостую
// (фонарик — апгрейд 1 удилища: save.rodLv[save.rod]>=2)
if(!nightNoLightToast){ toast('Ночью нужен фонарик — улучши удилище (первый апгрейд)'); nightNoLightToast=true; }
}else if(forceBite||Math.random()<biteChance()){
startBite();
}else{
// Спринт 4: пустой клёв — поплавок молчит, попробуем ещё раз
biteTimer=rand(2.5,5)/lure().bite;
}
}
break;
}
case 'APPROACH':
updateBiteFishIn(dt);
if(biteFish){
if(biteFish.t>=1){
state='BITE';
biteT=biteWindow(biteFish.sp);
sfx('bite'); vib(60);
addRipple(castTo.x,waterY+3,30);
}
}
break;
case 'BITE':
biteT-=dt;
bobber.y=waterY+5+Math.sin(time*22)*2;
if(Math.random()<0.2) addRipple(castTo.x,waterY+3,26);
if(biteT<=0){
if(biteFish) biteFish.mode='out';
state='WAIT'; biteTimer=rand(5,11)/lure().bite;
sfx('escape'); toast('Ушла… жми быстрее!');
}
break;
case 'REEL':
updateReel(dt);
if(state==='REEL'&&reel){
bobber.x=castTo.x+(reel.holding?Math.sin(time*30)*2:0);
bobber.y=waterY-2+Math.sin(time*9)*2;
if(Math.random()<0.25) addRipple(castTo.x,waterY+3,24);
}
break;
case 'SNAP':
snapT-=dt;
if(snapT<=0){
if(stateBoss && bossPhase<3){
// M4: босс-обрыв — не штраф: автоматом следующая (глубже) фаза, без ввода
reel.d=(bossPhase===1?0.6:1.0)*reel.d0; // фаза2: d=0.6·d0, фаза3: d=1.0·d0
reel.tension=0; // фазовый обрыв сбрасывает нагрузку, иначе «беспошлинный» обрыв ведёт к настоящему
bossPhase++;
state='REEL';
}else{
state='IDLE';
}
}
break;
}
syncDom();
}
function updateBiteFishIn(dt){
biteFish.t+=dt/0.45;
const t=Math.min(1,biteFish.t);
const e=t*t*(3-2*t);
biteFish.x=biteFish.sx+(castTo.x-biteFish.sx)*e;
biteFish.y=biteFish.sy+(waterY+14-biteFish.sy)*e;
biteFish.dir=castTo.x>biteFish.sx?1:-1;
}
function updateBiteFishOut(dt){
biteFish.t+=dt/0.6;
const dir=biteFish.x<W/2?-1:1;
biteFish.x+=dir*380*dt;
biteFish.y+=40*dt;
if(biteFish.t>=1||biteFish.x<-50||biteFish.x>W+50) biteFish=null;
}
function updateDecor(dt){
for(const c of clouds){ c.x+=c.v*dt; if(c.x>W+80) c.x=-80; }
for(let i=particles.length-1;i>=0;i--){
const p=particles[i];
p.life-=dt; p.vy+=p.g*dt; p.x+=p.vx*dt; p.y+=p.vy*dt;
if(p.life<=0||p.y>H) particles.splice(i,1);
}
for(let i=ripples.length-1;i>=0;i--){
const r=ripples[i];
r.r+=45*dt; r.a-=0.9*dt;
if(r.a<=0||r.r>r.max) ripples.splice(i,1);
}
if(shake>0) shake-=dt;
}
function syncDom(){
const showRecall=(state==='WAIT'||state==='APPROACH'||state==='BITE');
$('btnRecall').classList.toggle('hidden',!showRecall);
}
/* =========================================================
ОТРИСОВКА
========================================================= */
function rr(x,y,w,h,r){
ctx.beginPath();
ctx.moveTo(x+r,y);
ctx.arcTo(x+w,y,x+w,y+h,r);
ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r);
ctx.arcTo(x,y,x+w,y,r);
ctx.closePath();
}
const SKY_PHASES={ // M3: градиенты неба по фазе суток
morning:['#f5a623','#fce38a'],
day:['#7ec8e3','#cfeef7'],
evening:['#e44d26','#f16529'],
night:['#0c1445','#1a237e']
};
/* Визуал: плавные переходы неба/освещения — зона перехода в КОНЦЕ фазы
(ночь темнеет к концу вечера, рассвет — в конце ночи; на стыке t=0 чистое утро) */
const SKY_SEQ=['morning','day','evening','night'];
const SKY_BOUNDS=[0.25,0.5,0.75,1.0]; // переход к следующей фазе завершается на границе
const SKY_ZONE=0.08; // ширина переходной зоны
function hexLerp(a,b,k){
const pa=parseInt(a.slice(1),16),pb=parseInt(b.slice(1),16);
const r=Math.round(((pa>>16)&255)+(((pb>>16)&255)-((pa>>16)&255))*k);
const g=Math.round(((pa>>8)&255)+(((pb>>8)&255)-((pa>>8)&255))*k);
const bl=Math.round((pa&255)+((pb&255)-(pa&255))*k);
return 'rgb('+r+','+g+','+bl+')';
}
function skyColors(){
const t=dayTime;
for(let i=0;i<4;i++){
const b=SKY_BOUNDS[i];
if(t<b||i===3){
const a=SKY_PHASES[SKY_SEQ[i]], c=SKY_PHASES[SKY_SEQ[(i+1)%4]];
const z0=b-SKY_ZONE;
if(t>=z0){ const k=Math.max(0,Math.min(1,(t-z0)/SKY_ZONE)); return [hexLerp(a[0],c[0],k),hexLerp(a[1],c[1],k)]; }
return [a[0],a[1]];
}
}
return [SKY_PHASES.morning[0],SKY_PHASES.morning[1]];
}
/* Числовые величины (ночная доля и т.п.) с теми же переходами */
function phaseLerp(vals){
const t=dayTime;
for(let i=0;i<4;i++){
const b=SKY_BOUNDS[i];
if(t<b||i===3){
const z0=b-SKY_ZONE;
if(t>=z0){ const k=Math.max(0,Math.min(1,(t-z0)/SKY_ZONE)); return vals[i]+(vals[(i+1)%4]-vals[i])*k; }
return vals[i];
}
}
return vals[0];
}
/* Ночная доля освещения 0..1 (1 = глубокая ночь) — вода, звёзды, фонарь */
function nightMix(){ return phaseLerp([0,0,0.4,1]); }
let luminary={x:0,y:0,a:0,night:false}; // визуал: где солнце/луна и её яркость (для дорожки на воде)
function drawSky(){
const cols=skyColors();
const nm=nightMix();
const g=ctx.createLinearGradient(0,0,0,waterY);
g.addColorStop(0,cols[0]); g.addColorStop(1,cols[1]);
ctx.fillStyle=g; ctx.fillRect(0,0,W,waterY);
// звёзды — проступают с ночью, мерцают
if(nm>0.15){
for(const st of stars){
const a=nm*Math.max(0,Math.sin(time*st.sp+st.ph))*0.9;
if(a>0.03){
ctx.fillStyle='rgba(255,255,255,'+a.toFixed(3)+')';
ctx.beginPath(); ctx.arc(st.x,st.y,st.r,0,7); ctx.fill();
}
}
}
// M3: солнце/луна по дуге (визуал: мягкое свечение, гаснут/всходят в сумерках без наложения)
const ang=dayTime*2*Math.PI;
const bx=W/2+Math.cos(ang)*W*0.4, by=H*0.15-Math.sin(ang)*H*0.1;
luminary.x=bx; luminary.y=by; luminary.a=0; luminary.night=false;
if(nm<0.45){
const a=1-nm/0.45; // солнце садится к ночи
luminary.a=a;
ctx.globalAlpha=Math.max(0,a);
const glow=ctx.createRadialGradient(bx,by,6,bx,by,64);
glow.addColorStop(0,'rgba(255,225,130,0.5)'); glow.addColorStop(1,'rgba(255,225,130,0)');
ctx.fillStyle=glow; ctx.fillRect(bx-64,by-64,128,128);
ctx.fillStyle='#ffd700';
ctx.beginPath(); ctx.arc(bx,by,20,0,7); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.5)';
ctx.beginPath(); ctx.arc(bx-6,by-6,7,0,7); ctx.fill();
ctx.globalAlpha=1;
}else if(nm>0.55){
const a=(nm-0.55)/0.45; // луна всходит в глубине сумерек
luminary.a=a; luminary.night=true;
ctx.globalAlpha=Math.max(0,Math.min(1,a));
const glow=ctx.createRadialGradient(bx,by,8,bx,by,56);
glow.addColorStop(0,'rgba(220,230,255,0.32)'); glow.addColorStop(1,'rgba(220,230,255,0)');
ctx.fillStyle=glow; ctx.fillRect(bx-56,by-56,112,112);
ctx.fillStyle='#f5f5dc';
ctx.beginPath(); ctx.arc(bx,by,18,0,7); ctx.fill();
// кратеры
ctx.fillStyle='rgba(120,130,150,0.35)';
ctx.beginPath(); ctx.arc(bx+6,by+3,3.2,0,7); ctx.fill();
ctx.beginPath(); ctx.arc(bx-4,by+7,2.2,0,7); ctx.fill();
ctx.beginPath(); ctx.arc(bx-2,by-6,2.6,0,7); ctx.fill();
// серп — вырез цветом неба в точке луны
ctx.fillStyle=cols[0];
ctx.beginPath(); ctx.arc(bx+9,by-6,13.5,0,7); ctx.fill();
ctx.globalAlpha=1;
}
// облака (ночью приглушены)
ctx.fillStyle='rgba(255,255,255,'+(0.9-0.68*nm).toFixed(3)+')';
for(const c of clouds){
ctx.save(); ctx.translate(c.x,c.y); ctx.scale(c.s,c.s);
ctx.beginPath();
ctx.arc(0,0,14,0,7); ctx.arc(16,-6,12,0,7); ctx.arc(32,0,15,0,7); ctx.arc(16,6,13,0,7);
ctx.fill(); ctx.restore();
}
}
function drawWater(){
const nm=nightMix();
// градиент воды: день ↔ ночь, плавно (визуал)
const top=hexLerp('#2f9bd8','#0d2246',nm), bot=hexLerp('#0a2f52','#041226',nm);
const g=ctx.createLinearGradient(0,waterY,0,H);
g.addColorStop(0,top); g.addColorStop(0.55,hexLerp('#1f6fb0','#091c3a',nm)); g.addColorStop(1,bot);
ctx.fillStyle=g; ctx.fillRect(0,waterY,W,H-waterY);
// дорожка света от солнца/луны — мерцающие блики под светилом
if(luminary.a>0.05){
const depth=Math.min(1,Math.max(0,(luminary.y)/(waterY||1))); // светило низко — дорожка длиннее
const len=(H-waterY)*(0.18+0.3*depth);
const base=(luminary.night?0.12:0.2)*luminary.a;
ctx.fillStyle=luminary.night?'rgba(200,220,255,1)':'rgba(255,235,170,1)';
for(let y=waterY+6;y<waterY+len;y+=7){
const k=(y-waterY)/len;
const wob=Math.sin(time*2.2+y*0.55);
const a=Math.max(0,base*(1-k)*(0.45+0.55*wob));
if(a<0.02) continue;
ctx.globalAlpha=a;
const wdt=14+k*30+Math.sin(time*1.7+y)*4;
ctx.fillRect(luminary.x-wdt/2,y,wdt,2);
}
ctx.globalAlpha=1;
}
// блики
for(const s of sparkles){
const a=Math.max(0,Math.sin(time*1.5+s.ph))*0.25*(1-nm*0.5);
if(a>0.02){ ctx.fillStyle='rgba(255,255,255,'+a.toFixed(3)+')'; ctx.fillRect(s.x,s.y,3,1.5); }
}
// волна на поверхности + вторая, глубинная (визуал)
ctx.strokeStyle='rgba(255,255,255,'+(0.55-nm*0.25).toFixed(3)+')'; ctx.lineWidth=2;
ctx.beginPath();
for(let x=0;x<=W;x+=8){
const y=waterY+Math.sin(x*0.05+time*2)*2.5;
if(x===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
}
ctx.stroke();
ctx.strokeStyle='rgba(255,255,255,'+(0.2-nm*0.1).toFixed(3)+')'; ctx.lineWidth=1.5;
ctx.beginPath();
for(let x=0;x<=W;x+=10){
const y=waterY+7+Math.sin(x*0.04-time*1.4+1.7)*3;
if(x===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
}
ctx.stroke();
}
function drawFishShape(x,y,size,dir,hue,alpha,wob){
ctx.save(); ctx.translate(x,y); ctx.scale(dir,1);
const w=wob||0;
ctx.fillStyle='hsla('+hue+',55%,38%,'+alpha+')';
ctx.beginPath(); ctx.ellipse(0,0,size,size*0.45,0,0,7); ctx.fill();
// хвост качается (визуал)
ctx.save(); ctx.translate(-size*0.8,0); ctx.rotate(w);
ctx.beginPath();
ctx.moveTo(0,0); ctx.lineTo(-size*0.65,-size*0.42); ctx.lineTo(-size*0.65,size*0.42);
ctx.closePath(); ctx.fill();
ctx.restore();
// блик на спинке + глаз (визуал: живость, у силуэтов едва видны)
ctx.fillStyle='rgba(255,255,255,'+(alpha*0.22).toFixed(3)+')';
ctx.beginPath(); ctx.ellipse(-size*0.1,-size*0.16,size*0.55,size*0.18,-0.25,0,7); ctx.fill();
ctx.fillStyle='rgba(10,25,40,'+(alpha*0.8).toFixed(3)+')';
ctx.beginPath(); ctx.arc(size*0.6,-size*0.07,size*0.09,0,7); ctx.fill();
ctx.restore();
}
function drawAmbientFish(){
for(const f of ambient){
const y=waterY+20+f.depth*(H-waterY-50)+Math.sin(time*2+f.ph)*2;
drawFishShape(f.x,y,f.size,f.dir,f.hue,0.22+0.2*(1-f.depth),Math.sin(time*5+f.ph)*0.28);
}
}
function drawBoat(){
const bx=boatX, by=waterY+Math.sin(time*1.5)*1.5;
// блик-отражение под лодкой (визуал)
ctx.fillStyle='rgba(0,20,40,0.28)';
ctx.beginPath(); ctx.ellipse(bx,by+17,34,4.5,0,0,7); ctx.fill();
// лодка
const bg=ctx.createLinearGradient(bx-32,by,bx+32,by+15);
bg.addColorStop(0,'#c9773c'); bg.addColorStop(1,'#96521f');
ctx.fillStyle=bg;
ctx.beginPath();
ctx.moveTo(bx-32,by); ctx.lineTo(bx+32,by); ctx.lineTo(bx+21,by+15); ctx.lineTo(bx-21,by+15);
ctx.closePath(); ctx.fill();
ctx.fillStyle='#8a4519'; ctx.fillRect(bx-32,by,64,4);
// рыбак
ctx.fillStyle='#34495e';
ctx.fillRect(bx-9,by-18,12,12);
ctx.fillStyle='#e8b88a';
ctx.beginPath(); ctx.arc(bx-3,by-24,6.5,0,7); ctx.fill();
ctx.fillStyle='#c0392b';
ctx.beginPath(); ctx.arc(bx-3,by-27,6.5,Math.PI,0); ctx.fill();
// удочка
const swing=state==='CHARGE'?-power*0.35:0;
ctx.strokeStyle='#5d4023'; ctx.lineWidth=3; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(bx+2,by-14);
const tx=bx+34+swing*20, ty=by-38-swing*14;
ctx.lineTo(tx,ty); ctx.stroke();
tip={x:tx,y:ty};
// флажок
ctx.strokeStyle='#5d4023'; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(bx-24,by); ctx.lineTo(bx-24,by-22); ctx.stroke();
ctx.fillStyle='#f5a623';
const fl=Math.sin(time*4)*1.5; // флажок колышется (визуал)
ctx.beginPath(); ctx.moveTo(bx-24,by-22); ctx.lineTo(bx-12,by-18+fl); ctx.lineTo(bx-24,by-14); ctx.closePath(); ctx.fill();
// фонарь на носу ночью (визуал: тёплое пятно света)
if(nightMix()>0.35){
const a=(nightMix()-0.35)/0.65;
const lx=bx-24,ly=by-20;
const lg=ctx.createRadialGradient(lx,ly,2,lx,ly,46);
lg.addColorStop(0,'rgba(255,214,120,'+(0.4*a).toFixed(3)+')');
lg.addColorStop(1,'rgba(255,214,120,0)');
ctx.fillStyle=lg; ctx.fillRect(lx-46,ly-46,92,92);
ctx.fillStyle='rgba(255,224,150,'+(0.9*a).toFixed(3)+')';
ctx.beginPath(); ctx.arc(lx,ly,2.5,0,7); ctx.fill();
}
}
function drawLineTo(x,y,sag){
ctx.strokeStyle='rgba(230,240,255,0.8)'; ctx.lineWidth=1.2;
ctx.beginPath(); ctx.moveTo(tip.x,tip.y);
ctx.quadraticCurveTo((tip.x+x)/2,(tip.y+y)/2+sag,x,y);
ctx.stroke();
}
function drawBobber(){
const x=bobber.x,y=bobber.y;
// тёмная кромка воды под поплавком (визуал)
ctx.fillStyle='rgba(0,20,40,0.25)';
ctx.beginPath(); ctx.ellipse(x,y+6,7,1.8,0,0,7); ctx.fill();
ctx.fillStyle='#fff';
ctx.beginPath(); ctx.arc(x,y,5.5,0,7); ctx.fill();
ctx.fillStyle='#e74c3c';
ctx.beginPath(); ctx.arc(x,y,5.5,Math.PI,0); ctx.fill();
// блик (визуал)
ctx.fillStyle='rgba(255,255,255,0.85)';
ctx.beginPath(); ctx.arc(x-1.8,y-1.8,1.4,0,7); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,0.9)'; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.arc(x,y,5.5,0,7); ctx.stroke();
}
function drawLineAndBobber(){
// след поплавка: копим в забросе, чистим в остальных состояниях (визуал)
if(state==='CAST'){ bobberTrail.push({x:bobber.x,y:bobber.y}); if(bobberTrail.length>14) bobberTrail.shift(); }
else if(bobberTrail.length) bobberTrail.length=0;
if(state==='IDLE'||state==='CHARGE'||state==='MENU'||state==='CARD'){
// болтается грузило
const a=state==='CHARGE'?Math.sin(time*10)*power*4:Math.sin(time*1.2)*1.5;
ctx.strokeStyle='rgba(230,240,255,0.8)'; ctx.lineWidth=1.2;
ctx.beginPath(); ctx.moveTo(tip.x,tip.y); ctx.lineTo(tip.x+a,tip.y+22); ctx.stroke();
ctx.fillStyle='#e74c3c';
ctx.beginPath(); ctx.arc(tip.x+a,tip.y+25,3.5,0,7); ctx.fill();
return;
}
if(state==='SNAP'){
ctx.strokeStyle='rgba(230,240,255,0.8)'; ctx.lineWidth=1.2;
ctx.beginPath(); ctx.moveTo(tip.x,tip.y);
ctx.lineTo(tip.x+10+Math.sin(time*20)*6,tip.y+26); ctx.stroke();
ctx.globalAlpha=Math.min(1,snapT*2);
ctx.shadowColor='rgba(255,120,60,0.9)'; ctx.shadowBlur=14; // вспышка (визуал)
ctx.font='34px system-ui'; ctx.textAlign='center';
ctx.fillText('💥',castTo.x,waterY-20);
ctx.shadowBlur=0;
ctx.globalAlpha=1;
return;
}
if(state==='CAST'){
// след: затухающие точки по траектории (визуал)
for(let i=0;i<bobberTrail.length;i++){
const tp=bobberTrail[i];
ctx.fillStyle='rgba(255,255,255,'+(i/bobberTrail.length*0.35).toFixed(3)+')';
ctx.beginPath(); ctx.arc(tp.x,tp.y,1.6,0,7); ctx.fill();
}
drawLineTo(bobber.x,bobber.y,14); drawBobber(); return;
}
const sag=state==='REEL'?(reel.phase==='pull'?4:12):18+Math.sin(time*2)*3;
drawLineTo(bobber.x,bobber.y,sag);
drawBobber();
if(state==='BITE'){
const yy=bobber.y-24+Math.sin(time*12)*2;
ctx.font='800 28px system-ui'; ctx.textAlign='center';
ctx.lineWidth=4; ctx.strokeStyle='#fff';
ctx.strokeText('!',bobber.x,yy);
ctx.fillStyle='#ff3b30';
ctx.fillText('!',bobber.x,yy);
}
}
function drawHookedFish(){
if(state!=='REEL'||!reel) return;
const sp=reel.sp;
const k=reel.tired?0.35:1; // Спринт 4: уставшая рыба вялая — медленнее и амплитуда ниже
const x=castTo.x+Math.sin(time*7*k)*4*k;
const y=waterY+26+Math.sin(time*5*k)*3*k;
const dir=Math.sin(time*2.5*k)>0?1:-1;
const fs=8+(reel.w/sp.wMax)*10+sp.rarity;
if(reel.boss){ // босс: золотая аура (визуал)
const gg=ctx.createRadialGradient(x,y,4,x,y,fs*2.6);
gg.addColorStop(0,'rgba(255,215,94,0.35)'); gg.addColorStop(1,'rgba(255,215,94,0)');
ctx.fillStyle=gg; ctx.fillRect(x-fs*2.6,y-fs*2.6,fs*5.2,fs*5.2);
}
drawFishShape(x,y,reel.boss?fs*1.5:fs,dir,sp.hue,0.75,Math.sin(time*9*k)*0.3*k); // силуэт крупнее у тяжёлых, босс ×1.5
}
function drawParticles(){
for(const p of particles){
ctx.fillStyle='rgba('+p.color+','+(Math.max(0,p.life/p.max)).toFixed(3)+')';
ctx.beginPath(); ctx.arc(p.x,p.y,p.size,0,7); ctx.fill();
}
}
function drawRipples(){
for(const r of ripples){
ctx.strokeStyle='rgba(255,255,255,'+Math.max(0,r.a).toFixed(3)+')';
ctx.lineWidth=1.5;
ctx.beginPath(); ctx.ellipse(r.x,r.y,r.r,r.r*0.3,0,0,7); ctx.stroke();
}
}
function drawMeters(){
if(state!=='REEL'||!reel) return;
const r=rod(), sp=reel.sp;
const pulling=reel.phase==='pull';
// M4: босс-бой — золотая полоса фазы над метрами (визуал: пульс + точки фаз)
if(reel.boss){
const pulse=1+Math.sin(time*5)*0.08;
ctx.textAlign='center';
ctx.font='800 '+(16*pulse).toFixed(1)+'px system-ui';
ctx.fillStyle='#ffd75e';
ctx.shadowColor='rgba(255,180,40,0.8)'; ctx.shadowBlur=10;
ctx.fillText('⚠️ БОСС-РЫБА · ФАЗА '+bossPhase+'/3',W/2,84);
ctx.shadowBlur=0;
for(let i=1;i<=3;i++){
ctx.beginPath();
ctx.arc(W/2-20+i*20,94,4,0,7);
ctx.fillStyle=i<=bossPhase?'#ffd75e':'rgba(255,255,255,0.25)';
ctx.fill();
}
}
// --- нагрузка (вертикально справа) ---
const bw=14, bh=H*0.36, bx=W-30, by=H*0.46-bh/2;
ctx.fillStyle='rgba(0,0,0,0.35)'; rr(bx,by,bw,bh,7); ctx.fill();
const ratio=Math.min(1,reel.tension/reel.line);
const col=ratio<0.5?'#2ecc71':ratio<0.8?'#f1c40f':'#e74c3c';
if(ratio>0.01){
ctx.fillStyle=col;
if(ratio>0.8){ ctx.shadowColor='rgba(231,76,60,0.85)'; ctx.shadowBlur=12; } // перегруз: тревожное свечение
const fh=bh*ratio;
rr(bx+2,by+bh-2-fh,bw-4,Math.max(2,fh-2),5); ctx.fill();
ctx.shadowBlur=0;
}
ctx.strokeStyle='rgba(255,255,255,0.6)'; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.moveTo(bx-3,by); ctx.lineTo(bx+bw+3,by); ctx.stroke();
// Спринт 4: черта drag — выше неё при удержании леска скользит
const dgy=by+bh*(1-Math.min(1,r.drag/reel.line));
ctx.strokeStyle='rgba(255,255,255,0.9)'; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(bx-2,dgy); ctx.lineTo(bx+bw+2,dgy); ctx.stroke();
const Fm=fishForce(reel,reel.aggr,pulling);
const slipping=reel.holding&&Fm-r.drag>0;
ctx.font='16px system-ui'; ctx.textAlign='center';
ctx.fillText(slipping?'🌀':pulling?'⚡':(reel.holding?'💪':'·'),bx+bw/2,by-8);
// --- дистанция (снизу) ---
const dw=W-96, dx=22, dy=H-52, dh=13;
ctx.fillStyle='rgba(0,0,0,0.35)'; rr(dx,dy,dw,dh,7); ctx.fill();
// escape: красная заливка справа налево (рыба ускользает)
if(reel.escape>0.01){
const ew=(dw-4)*reel.escape;
ctx.fillStyle='rgba(231,76,60,0.7)';
rr(dx+dw-2-ew,dy+2,ew,dh-4,5); ctx.fill();
}
// rem = доля оставшейся дистанции: полоска «сгорает» к лодке, рыбка на её конце
const rem=Math.max(0,Math.min(1,reel.d/reel.d0));
if(rem>0.01){
const dg=ctx.createLinearGradient(dx,0,dx+dw,0);
dg.addColorStop(0,'#2e86c8'); dg.addColorStop(1,'#3fa7e8');
ctx.fillStyle=dg;
rr(dx+2,dy+2,Math.max(2,(dw-4)*rem),dh-4,5); ctx.fill();
}
// рыбка-маркер на конце полоски
const fx=dx+2+(dw-4)*rem;
ctx.font='20px system-ui'; ctx.textAlign='center';
ctx.fillText('🐟',fx,dy-6+Math.sin(time*10)*1.5);
ctx.font='18px system-ui';
ctx.fillText('⛵',dx+4,dy+dh+18);
// Спринт 4: выносливость — тонкая полоска под дистанцией (затухает к нулю)
// T2b: начинается после эмодзи ⛵ (dx+18), чтобы не перекрывать лодку
const sdh=5, sdy=dy+dh+3;
ctx.fillStyle='rgba(0,0,0,0.3)'; rr(dx+18,sdy,dw-18,sdh,2.5); ctx.fill();
if(reel.st>0.01){
ctx.fillStyle=reel.tired?'#7d5ba6':'#b07cc6';
rr(dx+19.5,sdy+1.5,Math.max(1.5,(dw-21)*reel.st),sdh-3,2); ctx.fill();
}
if(reel.tired){
ctx.font='13px system-ui'; ctx.textAlign='left';
ctx.fillText('💤',dx+dw+6,sdy+sdh-1);
}
// статус
ctx.font='700 15px system-ui'; ctx.textAlign='center';
if(pulling){
ctx.fillStyle='#ff6b5e';
ctx.shadowColor='rgba(255,60,40,'+(0.5+0.4*Math.sin(time*10)).toFixed(3)+')'; ctx.shadowBlur=8;
ctx.fillText('⚡ РЫБА ТЯНЕТ — ОТПУСТИ! ⚡',W/2,dy-30);
ctx.shadowBlur=0;
}else if(reel.tired){
ctx.fillStyle='#c9a0f5';
ctx.shadowColor='rgba(120,60,180,0.6)'; ctx.shadowBlur=6;
ctx.fillText('💤 Рыба устала — втягивай!',W/2,dy-30);
ctx.shadowBlur=0;
}else{
ctx.fillStyle='#7ef0a8';
ctx.shadowColor='rgba(0,0,0,0.6)'; ctx.shadowBlur=4;
ctx.fillText('Держи палец — втягивай!',W/2,dy-30);
ctx.shadowBlur=0;
}
}
function drawPowerBar(){
if(state!=='CHARGE') return;
const w=W*0.7,h=16,x=(W-w)/2,y=H-64;
ctx.fillStyle='rgba(0,0,0,0.4)'; rr(x,y,w,h,8); ctx.fill();
const g=ctx.createLinearGradient(x,0,x+w,0);
g.addColorStop(0,'#2ecc71'); g.addColorStop(0.6,'#f1c40f'); g.addColorStop(1,'#e74c3c');
ctx.fillStyle=g;
if(power>0.01){
const fw=Math.max(2,(w-4)*power);
rr(x+2,y+2,fw,h-4,6); ctx.fill();
// бегунок на конце заливки (визуал)
ctx.fillStyle='rgba(255,255,255,0.9)';
ctx.beginPath(); ctx.arc(x+2+fw-3,y+h/2,4,0,7); ctx.fill();
}
ctx.font='700 14px system-ui'; ctx.textAlign='center';
ctx.fillStyle='#fff';
ctx.shadowColor='rgba(0,0,0,0.65)'; ctx.shadowBlur=4;
ctx.fillText('Отпусти — бросок!',W/2,y-10);
ctx.shadowBlur=0;
}
function drawHint(){
if(state!=='IDLE'||save.casts>=5) return;
const a=0.55+Math.sin(time*3)*0.25;
ctx.globalAlpha=a;
ctx.font='700 17px system-ui'; ctx.textAlign='center';
ctx.fillStyle='#fff';
ctx.shadowColor='rgba(0,0,0,0.65)'; ctx.shadowBlur=5;
ctx.fillText('👆 Держи палец — заряжай бросок',W/2,H-70);
ctx.shadowBlur=0;
ctx.globalAlpha=1;
}
function draw(){
ctx.clearRect(0,0,W,H);
ctx.save();
if(shake>0){
ctx.translate(rand(-1,1)*shake*8,rand(-1,1)*shake*8);
}
drawSky();
drawWater();
drawAmbientFish();
if(biteFish&&biteFish.mode!=='out') drawFishShape(biteFish.x,biteFish.y,12+biteFish.sp.rarity*2.5,biteFish.dir,biteFish.sp.hue,0.8,Math.sin(time*8)*0.25);
else if(biteFish&&biteFish.mode==='out') drawFishShape(biteFish.x,biteFish.y,12+biteFish.sp.rarity*2.5,biteFish.dir,biteFish.sp.hue,0.5,Math.sin(time*8)*0.25);
drawBoat();
drawLineAndBobber();
drawHookedFish();
drawRipples();
drawParticles();
drawMeters();
drawPowerBar();
drawHint();
ctx.restore();
}
/* =========================================================
ЦИКЛ
========================================================= */
let last=performance.now();
function frame(now){
const dt=Math.min(0.05,(now-last)/1000); last=now;
time+=dt;
setDayTime(dayTime+dt/DAY_LENGTH); // M3: день/ночь идут в реальном времени
updateDecor(dt);
if(!uiOpen) updateGame(dt);
draw();
requestAnimationFrame(frame);
}
/* =========================================================
ВВОД
========================================================= */
function onDown(){
ac();
if(uiOpen) return;
if(state==='IDLE'){ state='CHARGE'; charge=0; power=0; }
else if(state==='APPROACH'||state==='BITE'){ hook(); }
else if(state==='REEL'&&reel){ reel.holding=true; }
}
function onUp(){
if(state==='CHARGE') doCast();
else if(state==='REEL'&&reel) reel.holding=false;
}
canvas.addEventListener('pointerdown',e=>{ e.preventDefault(); onDown(); });
window.addEventListener('pointerup',onUp);
window.addEventListener('pointercancel',onUp);
document.addEventListener('contextmenu',e=>e.preventDefault());
/* =========================================================
UI: МАГАЗИН / ДНЕВНИК / КАРТОЧКА
========================================================= */
let shopTab='tackle';
function openShop(){ uiOpen=true; $('shop').classList.remove('hidden'); renderShop(); sfx('click'); }
function closeShop(){ uiOpen=false; $('shop').classList.add('hidden'); sfx('click'); }
function setTab(t){ shopTab=t; renderShop();
$('tabTackle').classList.toggle('active',t==='tackle');
$('tabJournal').classList.toggle('active',t==='journal');
$('tabRewards').classList.toggle('active',t==='rewards');
$('tabAch').classList.toggle('active',t==='ach');
}
function lvPips(lv){ let s=''; for(let k=1;k<=4;k++) s+=k<=lv?'●':'○'; return s; }
function itemRow(item,i,type,owned,cur){
const list=type==='rod'?RODS:LURES;
const lvMap=type==='rod'?(save.rodLv||{}):(save.lureLv||{});
const costMap=type==='rod'?ROD_UPG_COST:LURE_UPG_COST;
const nextTxt=type==='rod'?ROD_UPG_NEXT:LURE_UPG_NEXT;
const lv=owned?Math.max(1,Math.min(4,lvMap[i]>=1?lvMap[i]:1)):1;
// M5: гейтинг — снасть №i открывается, когда снасть №(i−1) того же типа на 4 ур.
const prevLv=i>0?((lvMap[list[i-1].id]>=1)?lvMap[list[i-1].id]:1):0;
const locked=!owned&&i>0&&prevLv<4;
let btns='';
if(owned){
btns+=cur?'<button class="buybtn current" disabled>✓ Выбрано</button>'
:'<button class="buybtn owned" data-act="use" data-type="'+type+'" data-i="'+i+'">Выбрать</button>';
if(lv<4) btns+='<button class="buybtn upgbt" data-act="upgrade" data-type="'+type+'" data-i="'+i+'">⬆ '+costMap[lv-1]+' 🪙</button>';
}else if(!locked){
btns='<button class="buybtn" data-act="buy" data-type="'+type+'" data-i="'+i+'">'+item.cost+' 🪙</button>';
}
let desc=item.desc;
if(locked) desc='Откроется: улучши «'+list[i-1].name+'» до 4 ур.';
else if(owned&&lv<4) desc+=' · далее: '+nextTxt[lv-1];
const lvTag=owned?' <span class="lvtag">'+lvPips(lv)+' '+lv+' ур.</span>':'';
return '<div class="item'+(locked?' locked':'')+'"><div><div class="itemname">'+item.name+lvTag+'</div>'+
'<div class="itemdesc">'+desc+'</div></div>'+
(btns?'<div class="rowbtns">'+btns+'</div>':'<div class="rowbtns"><button class="buybtn" disabled>🔒</button></div>')+'</div>';
}
function renderShop(){
const body=$('shopBody');
if(shopTab==='tackle'){
let h='<div class="section-title">Удилища</div>';
RODS.forEach((r,i)=>{ h+=itemRow(r,i,'rod',save.rods.indexOf(i)>=0,save.rod===i); });
h+='<div class="section-title">Приманки</div>';
LURES.forEach((l,i)=>{ h+=itemRow(l,i,'lure',save.lures.indexOf(i)>=0,save.lure===i); });
body.innerHTML=h;
}else if(shopTab==='journal'){
let h='<div class="statsline">Бросков: '+save.casts+' · Заработано: '+save.earned+' 🪙</div>';
for(const s of SPECIES){
const c=save.caught[s.id]||{n:0,best:0,sn:0};
if(c.n>0){
const sn=c.sn||0;
h+='<div class="jrow"><span class="je">'+s.emoji+'</span><span class="jn"><b>'+s.name+'</b>'+(sn>0?' <span class="jsn">✨'+sn+'</span>':'')+'<br>'+
'<span style="font-size:11px;color:'+RARITY_COLORS[s.rarity]+'">'+RARITY_NAMES[s.rarity]+' · '+s.price+' 🪙/кг</span></span>'+
'<span class="jc">×'+c.n+'<br>макс '+c.best.toFixed(1)+' кг</span></div>';
}else{
h+='<div class="jrow unknown"><span class="je">❓</span><span class="jn">???</span>'+
'<span class="jc">не пойман</span></div>';
}
}
body.innerHTML=h;
}else if(shopTab==='rewards'){
// 🎯 Награды: стрик входа + квесты дня
const st=save.streak;
const idx=st.days>0?streakIdx(st.days):0;
let h='<div class="section-title">Стрик входа</div><div class="streak">';
for(let i=1;i<=7;i++){
h+='<span class="st-dot'+(st.days>0&&i<=idx?' on':'')+(st.days>0&&i===idx?' cur':'')+'">'+STREAK_REWARDS[i-1]+'</span>';
}
h+='</div>';
h+='<div class="statsline">День '+(st.days||0)+' · далее +'+nextStreakReward(st.days||1)+' 🪙</div>';
h+='<div class="section-title">Квесты дня</div>';
const ql=(save.quests&&save.quests.list)||[];
if(ql.length){
for(const q of ql){
h+='<div class="jrow'+(q.done?' qdone':'')+'"><span class="je">'+questIcon(q)+'</span>'+
'<span class="jn"><b>'+questText(q)+'</b></span>'+
'<span class="jc">'+(q.done?'✓ +'+q.reward+' 🪙':q.prog+'/'+q.goal)+'</span></div>';
}
}else{
h+='<div class="statsline">Квесты на сегодня закончились — заходи завтра!</div>';
}
body.innerHTML=h;
}else if(shopTab==='ach'){
// 🏅 Достижения: выданные — яркие, остальные приглушённые
let h='<div class="section-title">Достижения</div>';
for(const a of ACHIEVEMENTS){
const got=!(!(save.ach&&save.ach[a.id]));
h+='<div class="jrow'+(got?' got':' locked')+'"><span class="je">'+a.icon+'</span>'+
'<span class="jn"><b>'+a.name+'</b><br><span class="adesc">'+a.desc+' · '+bonusText(a.bonus)+'</span></span>'+
(got?'<span class="jc">✓</span>':'')+'</div>';
}
body.innerHTML=h;
}
}
function shopClick(e){
const b=e.target.closest('[data-act]');
if(!b) return;
const type=b.dataset.type, i=+b.dataset.i;
const list=type==='rod'?RODS:LURES;
const owned=type==='rod'?save.rods:save.lures;
if(!save.rodLv||typeof save.rodLv!=='object') save.rodLv={};
if(!save.lureLv||typeof save.lureLv!=='object') save.lureLv={};
const lvMap=type==='rod'?save.rodLv:save.lureLv;
const costMap=type==='rod'?ROD_UPG_COST:LURE_UPG_COST;
const item=list[i];
if(b.dataset.act==='buy'){
if(i>0&&lvMap[list[i-1].id]<4){ sfx('no'); toast('Сначала улучши «'+list[i-1].name+'» до 4 ур.'); return; } // M5: гейтинг
if(save.coins<item.cost){ sfx('no'); toast('Не хватает монет 🪙'); return; }
save.coins-=item.cost; owned.push(i); lvMap[i]=1; // куплено — уровень 1
if(type==='rod') save.rod=i; else save.lure=i;
persist(); updateHUD(); sfx('coin'); renderShop();
toast('Куплено: '+item.name+'!');
checkAchievements();
}else if(b.dataset.act==='upgrade'){
if(owned.indexOf(i)<0){ sfx('no'); return; } // M5: не куплено — апгрейд недоступен (паттерн защиты buy)
const lv=(lvMap[i]>=1)?lvMap[i]:1;
if(lv>=4) return;
const cost=costMap[lv-1];
if(save.coins<cost){ sfx('no'); toast('Не хватает монет 🪙'); return; }
save.coins-=cost; lvMap[i]=lv+1; // M5: апгрейд — уровень++
persist(); updateHUD(); sfx('coin'); renderShop();
toast('Улучшено: '+item.name+' → '+(lv+1)+' ур.!');
checkAchievements();
}else{
if(type==='rod') save.rod=i; else save.lure=i;
persist(); updateHUD(); sfx('click'); renderShop();
}
}
function showCard(sp,w,val,rec,shiny,boss){
$('cardRarity').textContent=RARITY_NAMES[sp.rarity];
$('cardRarity').style.color=RARITY_COLORS[sp.rarity];
$('cardEmoji').textContent=sp.emoji;
$('cardName').textContent=sp.name+(boss?'-БОСС':''); // M4: имя босса
$('cardSub').textContent=w.toFixed(1)+' кг · '+sp.price+' 🪙/кг';
$('cardVal').textContent='+'+val+' 🪙';
$('cardRecord').classList.toggle('hidden',!rec);
// шини: бейдж + золотое оформление карточки
$('cardShiny').classList.toggle('hidden',!shiny);
$('card').classList.toggle('shiny-card',!!shiny);
// M4: босс: бейдж 🏆 + золотое оформление карточки
$('cardBoss').classList.toggle('hidden',!boss);
$('card').classList.toggle('boss-card',!!boss);
$('card').classList.remove('hidden');
}
/* =========================================================
КНОПКИ
========================================================= */
$('btnPlay').addEventListener('click',()=>{
ac(); sfx('click');
$('menu').classList.add('hidden');
uiOpen=false; state='IDLE';
});
$('btnShop').addEventListener('click',openShop);
$('btnCloseShop').addEventListener('click',closeShop);
$('tabTackle').addEventListener('click',()=>{ setTab('tackle'); sfx('click'); });
$('tabJournal').addEventListener('click',()=>{ setTab('journal'); sfx('click'); });
$('tabRewards').addEventListener('click',()=>{ setTab('rewards'); sfx('click'); });
$('tabAch').addEventListener('click',()=>{ setTab('ach'); sfx('click'); });
$('shopBody').addEventListener('click',shopClick);
$('btnMute').addEventListener('click',()=>{
save.muted=!save.muted; persist(); updateHUD(); sfx('click');
});
$('btnRecall').addEventListener('click',()=>{ recall(); });
$('btnReset').addEventListener('click',()=>{
sfx('click');
uiOpen=true; // пауза, пока идёт подтверждение
$('resetDlg').classList.remove('hidden');
});
$('btnResetNo').addEventListener('click',()=>{
sfx('click');
$('resetDlg').classList.add('hidden');
uiOpen=(state==='MENU');
});
$('btnResetYes').addEventListener('click',()=>{
const muted=save.muted; // настройка звука — не игровые данные, оставляем
save=freshSave(muted);
persist();
refreshQuests(); // квесты дня; стрик начнётся с первой загрузки (чистый старт, coins=0)
reel=null; biteFish=null;
stateBoss=false; bossPhase=0; // M4: босс-бой сброшен
forceBite=false; forceBoss=false; // T2b: тест-форсы тоже сбрасываем (симметрия)
$('resetDlg').classList.add('hidden');
$('shop').classList.add('hidden');
$('card').classList.add('hidden');
state='MENU'; uiOpen=true;
$('menu').classList.remove('hidden');
updateHUD(); renderShop();
sfx('no'); vib(50);
toast('Прогресс сброшен 🔄');
});
$('btnCardOk').addEventListener('click',()=>{
sfx('click');
$('card').classList.add('hidden');
state='IDLE';
});
/* =========================================================
СТАРТ
========================================================= */
loadSave();
resize();
initFish();
updateHUD();
requestAnimationFrame(frame);
</script>
</body>
</html>