2740 lines
151 KiB
HTML
2740 lines
151 KiB
HTML
<!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;-webkit-touch-callout: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}
|
||
.hudleft{display:flex;gap:8px;align-items:center}
|
||
#coins,#weather{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;flex-direction:column;gap:6px;flex-shrink:0;max-width:48%;align-items:stretch}
|
||
.rowbtns .buybtn{min-width:0;white-space:normal}
|
||
.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}
|
||
/* M6: мини-счётчики вариантов в дневнике (обычная/серебряная/золотая/радужная) */
|
||
.jrow .jv{font-weight:800;font-size:11px;margin-left:5px;white-space:nowrap}
|
||
.jrow .jv-n{color:#8fb8dd}
|
||
.jrow .jv-sv{color:#dbe7f3}
|
||
.jrow .jv-sn{color:#ffd75e}
|
||
.jrow .jv-rb{color:#c56cf0}
|
||
.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)}}
|
||
/* M6: бейдж варианта и оформление карточки (silver/gold/rainbow; normal — без оформления) */
|
||
.variant-badge{display:inline-block;font-weight:800;font-size:14px;letter-spacing:1px;padding:4px 14px;border-radius:999px;margin-left:8px;
|
||
border:1px solid #fff;animation:recpulse .8s ease-in-out infinite}
|
||
#card.silver-card .variant-badge{background:linear-gradient(#f7fafd,#c3d4e6);color:#2f4d6d;border-color:#dbe7f3;box-shadow:0 0 14px rgba(200,220,240,.8)}
|
||
#card.gold-card .variant-badge{background:linear-gradient(#fff6c9,#ffd75e);color:#6b4a00;border-color:#ffd75e;box-shadow:0 0 14px rgba(255,215,94,.75)}
|
||
#card.rainbow-card .variant-badge{background:linear-gradient(#f3ddff,#c56cf0);color:#4b1a70;border-color:#e0b3ff;box-shadow:0 0 14px rgba(197,108,240,.8)}
|
||
#card.silver-card .panel{border-color:#c3d4e6;box-shadow:0 0 34px rgba(200,220,240,.45),0 10px 40px rgba(0,0,0,.5)}
|
||
#card.silver-card .bigemoji{filter:grayscale(.85) brightness(1.25) drop-shadow(0 0 12px rgba(210,228,245,.9))}
|
||
#card.gold-card .panel{border-color:#ffd75e;box-shadow:0 0 34px rgba(255,215,94,.5),0 10px 40px rgba(0,0,0,.5)}
|
||
#card.gold-card .bigemoji{filter:drop-shadow(0 0 12px rgba(255,215,94,.9))}
|
||
#card.rainbow-card .panel{border-color:#c56cf0;box-shadow:0 0 34px rgba(197,108,240,.5),0 10px 40px rgba(0,0,0,.5)}
|
||
#card.rainbow-card .bigemoji{animation:bob 1.2s ease-in-out infinite,rainbowhue 2.4s linear infinite}
|
||
@keyframes rainbowhue{from{filter:hue-rotate(0deg) drop-shadow(0 0 12px rgba(255,120,220,.8))}to{filter:hue-rotate(360deg) drop-shadow(0 0 12px rgba(120,220,255,.8))}}
|
||
.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}}
|
||
|
||
/* Y2: rewarded «монеты ×2» (карточка улова) — тёмно-синяя в духе карточки,
|
||
янтарная обводка «читается» как бонус; простой текстовый button (треб. 1.16 —
|
||
НЕ имитация рекламного блока платформы) */
|
||
.rvbtn{width:100%;min-height:44px;margin-top:10px;padding:11px 12px;font-size:15px;font-weight:800;color:#ffe9a8;
|
||
background:linear-gradient(#14406b,#0b2a49);border:1.5px solid #ffd75e;border-radius:12px;
|
||
box-shadow:0 0 12px rgba(255,215,94,.22),inset 0 1px 0 rgba(255,255,255,.08);
|
||
transition:transform .12s,box-shadow .12s,filter .12s}
|
||
.rvbtn:hover{filter:brightness(1.18);box-shadow:0 0 16px rgba(255,215,94,.4),inset 0 1px 0 rgba(255,255,255,.1)}
|
||
.rvbtn:active{transform:scale(.97)}
|
||
.rvbtn:disabled{opacity:.55;cursor:default;filter:none;transform:none}
|
||
|
||
.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,#weather{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 class="hudleft">
|
||
<div id="coins">🪙 0</div>
|
||
<span id="weather" class="hidden"></span>
|
||
</div>
|
||
<div class="hudbtns">
|
||
<button id="btnShop">🛒</button>
|
||
<button id="btnReset" title="Сбросить прогресс">🗑️</button>
|
||
<button id="btnMute">🔊</button>
|
||
<button id="btnPause" class="hidden" title="Пауза">⏸</button>
|
||
</div>
|
||
</div>
|
||
<button id="btnRecall" class="hidden">↩</button>
|
||
<div id="toast"></div>
|
||
|
||
<!-- Меню -->
|
||
<div id="menu" class="overlay">
|
||
<div class="panel">
|
||
<div class="title" id="menuTitle">🎣 РЫБАЛКА</div>
|
||
<!-- Y5: i18n — содержимое <p> и кнопки заполняет renderMenu() из T (RU-значения те же) -->
|
||
<p id="menuP1">👆 <b>Держи палец</b> — заряжай бросок,<br>
|
||
<b>отпусти</b> — кидай удочку.<br>
|
||
Дальше бросок = реже и жирнее рыба!</p>
|
||
<p id="menuP2" style="margin-top:8px">Видишь <b style="color:#ff6b5e">!</b> — быстро жми, чтобы зацепить.<br>
|
||
При вываживании: тяни, когда рыба отдыхает,<br>
|
||
отпусти, когда она <b>тянет</b> — иначе лопнет!<br>
|
||
Не тяни слишком долго — рыба <b>сбегает</b> (красная полоса)!</p>
|
||
<p id="menuP3" style="margin-top:8px">У каждой рыбы свой <b>вес</b>, продажа — по цене за кг.<br>
|
||
У удочек есть <b>предел веса</b>: перевесишь — шнур лопнет! <br>
|
||
Лучшие снасти = больше шансов на трофей 🏆</p>
|
||
<p id="menuP4" 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" id="shopTitle">🛒 Лавка рыбака</div>
|
||
<div class="tabs">
|
||
<button id="tabTackle" class="active" title="Снасти">🎣</button>
|
||
<button id="tabJournal" title="Дневник">📔</button>
|
||
<button id="tabRewards" title="Награды">🎯</button>
|
||
<button id="tabAch" title="Достижения">🏅</button>
|
||
<button id="tabSkins" title="Скины">🎨</button>
|
||
<button id="tabLocations" title="Локации">🗺️</button>
|
||
</div>
|
||
<div id="shopBody"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Подтверждение сброса -->
|
||
<div id="resetDlg" class="overlay hidden">
|
||
<div class="panel">
|
||
<div class="panelhead" id="resetTitle">🗑️ Сбросить прогресс?</div>
|
||
<p id="resetText" 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>
|
||
|
||
<!-- Пауза (треб. Яндекс Игр 6.3) -->
|
||
<div id="pause" class="overlay hidden">
|
||
<div class="panel">
|
||
<div class="panelhead" id="pauseTitle">⏸ Пауза</div>
|
||
<div class="twobtns">
|
||
<button id="btnPauseResume" class="big">Продолжить</button>
|
||
<button id="btnPauseMenu" class="big">В меню</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="variant-badge hidden" id="cardVariant">ЗОЛОТАЯ</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>
|
||
<!-- Y2: rewarded-видео «монеты ×2» (чистый бонус; 4.5.1 — что получим, написано прямо на кнопке) -->
|
||
<button id="btnRv" class="rvbtn hidden" title="Посмотрите рекламу — двойная цена за этот улов">📺 Реклама: ×2 за этот улов</button>
|
||
<div class="twobtns">
|
||
<button id="btnCardOk" class="big">Отлично!</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
'use strict';
|
||
/* =========================================================
|
||
ДАННЫЕ
|
||
========================================================= */
|
||
const SPECIES=[
|
||
{id:'carass', name:'Карась', nameEn:'Crucian carp', emoji:'🐟', rarity:0, weight:100, minDist:0.00, strength:0.25, wMin:0.1, wMax:0.8, price:20, hue:45},
|
||
{id:'perch', name:'Окунь', nameEn:'Perch', emoji:'🐠', rarity:0, weight:55, minDist:0.05, strength:0.30, wMin:0.1, wMax:1.5, price:22, hue:120},
|
||
{id:'bream', name:'Лещ', nameEn:'Bream', emoji:'🐠', rarity:1, weight:26, minDist:0.30, strength:0.40, wMin:0.3, wMax:3.0, price:25, hue:210},
|
||
{id:'carp', name:'Карп', nameEn:'Carp', emoji:'🐡', rarity:1, weight:22, minDist:0.35, strength:0.45, wMin:0.5, wMax:8.0, price:28, hue:30},
|
||
{id:'pike', name:'Щука', nameEn:'Pike', emoji:'🦈', rarity:2, weight:9, minDist:0.50, strength:0.60, wMin:1.0, wMax:10.0, price:35, hue:100},
|
||
{id:'catfish',name:'Сом', nameEn:'Catfish', emoji:'🐡', rarity:2, weight:5, minDist:0.65, strength:0.75, wMin:2.0, wMax:20.0, price:45, hue:260},
|
||
{id:'sturge', name:'Осётр', nameEn:'Sturgeon', 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:'Золотая рыбка', nameEn:'Golden Fish', emoji:'🎏', rarity:4, weight:1.2, minDist:0.90, strength:0.90, wMin:0.05, wMax:1.0, price:1500, hue:45},
|
||
/* Этап 5: жильцы Озёр (в реку не заходят — ролл реки по RIVER_*; порядок 0..7 не трогаем);
|
||
weight у новых видов не используется для ролла: река — RIVER_*, Озёра — LAKE_DAY_WEIGHTS/LAKE_NIGHT_WEIGHTS */
|
||
{id:'zander', name:'Судак', nameEn:'Zander', emoji:'🐟', rarity:2, weight:8, minDist:0.45, strength:0.60, wMin:1.0, wMax:6.0, price:40, hue:180},
|
||
{id:'chuch', name:'Хариус', nameEn:'Grayling', emoji:'🐠', rarity:1, weight:18, minDist:0.35, strength:0.35, wMin:0.2, wMax:2.0, price:30, hue:25},
|
||
{id:'crayfish',name:'Гигантский рак', nameEn:'Giant Crayfish', emoji:'🦞', rarity:3, weight:1.5, minDist:0.70, strength:0.50, wMin:0.2, wMax:1.0, price:200, hue:0},
|
||
];
|
||
const RARITY_NAMES=['Обычная','Необычная','Редкая','Эпическая','Легендарная'];
|
||
const RARITY_NAMES_EN=['Common','Uncommon','Rare','Epic','Legendary']; // Y5: i18n — EN-названия редкостей
|
||
const RARITY_COLORS=['#9fc3e2','#2ecc71','#4da3ff','#c56cf0','#ffd75e'];
|
||
|
||
/* M3: ночная таблица — те же виды, но веса спавна смещены к ночной рыбе (клон SPECIES);
|
||
Этап 5: +ночные веса новых видов (zander 6 / chuch 10 / crayfish 2.5) */
|
||
const NIGHT_WEIGHTS={carass:40,perch:80,bream:10,carp:8,pike:22,catfish:18,sturge:4,gold:1.2,zander:6,chuch:10,crayfish:2.5};
|
||
const SPECIES_NIGHT=SPECIES.map(s=>Object.assign({},s,{weight:NIGHT_WEIGHTS[s.id]}));
|
||
|
||
/* Этап 5: Локации — Река (родная вода, бесплатно) и Озёра (100000 🪙, новые рыбы).
|
||
water: [dayTop, dayMid, dayBot, nightTop, nightMid, nightBot] — базовые пары цветов
|
||
градиента drawWater(): день ↔ ночь плавно через nightMix (логика не меняется) */
|
||
const LOCATIONS=[
|
||
{id:0, name:'Река', nameEn:'River', cost:0, desc:'родная вода', descEn:'home waters',
|
||
water:['#2f9bd8','#1f6fb0','#0a2f52','#0d2246','#091c3a','#041226']},
|
||
{id:1, name:'Озёра', nameEn:'Lakes', cost:100000, desc:'новые рыбы: судак, хариус, гигантский рак', descEn:'new fish: zander, grayling, giant crayfish',
|
||
water:['#3d9c8a','#20726a','#0c423c','#0f2b33','#0a2129','#04141a']}
|
||
];
|
||
/* Река — только 8 базовых видов: роллы реки остаются прежними (новые виды заходят только в Озёра).
|
||
Фильтр по id: порядок и веса записей — ровно как в SPECIES/SPECIES_NIGHT */
|
||
const RIVER_IDS=['carass','perch','bream','carp','pike','catfish','sturge','gold'];
|
||
const RIVER_SPECIES=SPECIES.filter(s=>RIVER_IDS.indexOf(s.id)>=0);
|
||
const RIVER_SPECIES_NIGHT=SPECIES_NIGHT.filter(s=>RIVER_IDS.indexOf(s.id)>=0);
|
||
/* Озёра — все 11 видов: «рыбное» озеро (день) и ночное смещение к хищникам (ночь:
|
||
больше окуня/щуки/сома/судача, меньше карася/хариуса, рак 2).
|
||
SPECIES_NIGHT — источник весов только для реки (RIVER_SPECIES_NIGHT); у Озёр свои
|
||
LAKE_NIGHT_WEIGHTS — правьте ночные озёра здесь */
|
||
const LAKE_DAY_WEIGHTS={carass:60,perch:40,bream:30,carp:25,zander:14,chuch:22,pike:12,catfish:8,crayfish:1.5,sturge:3,gold:1.2};
|
||
const LAKE_NIGHT_WEIGHTS={carass:25,perch:70,bream:10,carp:8,zander:18,chuch:10,pike:24,catfish:16,crayfish:2,sturge:4,gold:1.2};
|
||
const LAKE_SPECIES=SPECIES.map(s=>Object.assign({},s,{weight:LAKE_DAY_WEIGHTS[s.id]}));
|
||
const LAKE_SPECIES_NIGHT=SPECIES.map(s=>Object.assign({},s,{weight:LAKE_NIGHT_WEIGHTS[s.id]}));
|
||
|
||
/* M6: шанс gold-варианта (let — автотест форсирует значения, как раньше) */
|
||
let SHINY_CHANCE=0.03;
|
||
|
||
/* M6: варианты рыб — таблица. Ролл по порядку: gold (SHINY_CHANCE) → silver → rainbow, иначе normal (E[mult]≈1.13) */
|
||
const VARIANTS=[
|
||
{id:'normal', name:'Обычная', nameEn:'Normal', mult:1, chance:0, css:''},
|
||
{id:'silver', name:'Серебряная', nameEn:'Silver', mult:2, chance:0.05, css:'silver'},
|
||
{id:'gold', name:'Золотая', nameEn:'Gold', mult:3, chance:0.03, css:'gold'},
|
||
{id:'rainbow', name:'Радужная', nameEn:'Rainbow', mult:5, chance:0.005, css:'rainbow'},
|
||
];
|
||
let forceVariant=null; // M6: тест-форс варианта ('silver'/'gold'/'rainbow'/null — всегда даёт его, паттерн forceBoss)
|
||
function variantChance(v){
|
||
let c=v.id==='gold'?SHINY_CHANCE:v.chance; // chance gold — живая SHINY_CHANCE
|
||
// M8: дождь — варианты ярче (silver/gold/rainbow ×2,5; normal не трогаем; босс всегда normal — не доходит сюда)
|
||
if(v.id!=='normal'&&weatherActive()==='rain') c*=WEATHER.rain.variantMult;
|
||
return c;
|
||
}
|
||
/* M6: ролл варианта улова */
|
||
function rollVariant(){
|
||
if(forceVariant){
|
||
const v=VARIANTS.find(x=>x.id===forceVariant);
|
||
if(v) return v;
|
||
}
|
||
for(const id of ['gold','silver','rainbow']){
|
||
const v=VARIANTS.find(x=>x.id===id);
|
||
if(Math.random()<variantChance(v)) return v;
|
||
}
|
||
return VARIANTS.find(x=>x.id==='normal');
|
||
}
|
||
|
||
/* M4: босс-рыба (Осётр-босс) — тест-форс появления (let — автотест, как SHINY_CHANCE) */
|
||
let forceBoss=false;
|
||
|
||
/* Спринт 4: пустые клёвы — тест-форс клёва (let — автотест, паттерн forceBoss) */
|
||
let forceBite=false;
|
||
|
||
/* M8: погода — дождь (варианты ×2,5) и туман (клёв ×1,5); события раз в 120–240 с, длятся 120 с */
|
||
const WEATHER={
|
||
rain:{icon:'🌧️', variantMult:2.5, toast:'Дождь: шанс серебряной/золотой/радужной ×2,5'},
|
||
fog:{icon:'🌫️', biteMult:1.5, toast:'Туман: клёв чаще ×1,5'}
|
||
};
|
||
const WEATHER_GAP_MIN=120, WEATHER_GAP_MAX=240; // сек до следующего события (случайный разброс)
|
||
const WEATHER_DURATION=120; // сек активного события
|
||
let forceWeather=null; // M8: тест-форс погоды ('rain'/'fog'/null — активна всегда, паттерн forceBoss)
|
||
|
||
const RODS=[
|
||
{id:0, name:'Дедова удочка', nameEn:"Grandpa's Rod", icon:'🧹', cost:0, maxW:3, line:100, drag:34, reel:1.0, size:0.00, desc:'До 3 кг · Нагрузка 100 · Drag 34 · Подсечка ×1.0', descEn:'Up to 3 kg · Load 100 · Drag 34 · Hookset ×1.0'},
|
||
{id:1, name:'Стеклопластик', nameEn:'Fiberglass', icon:'🎣', cost:875, maxW:8, line:140, drag:47, reel:1.3, size:0.08, desc:'До 8 кг · Нагрузка 140 · Drag 47 · Подсечка ×1.3', descEn:'Up to 8 kg · Load 140 · Drag 47 · Hookset ×1.3'},
|
||
{id:2, name:'Карбоновая', nameEn:'Carbon', icon:'🏹', cost:4500, maxW:18, line:190, drag:63, reel:1.7, size:0.16, desc:'До 18 кг · Нагрузка 190 · Drag 63 · Подсечка ×1.7', descEn:'Up to 18 kg · Load 190 · Drag 63 · Hookset ×1.7'},
|
||
{id:3, name:'Титан', nameEn:'Titanium', icon:'💪', cost:15000, maxW:40, line:260, drag:87, reel:2.3, size:0.28, desc:'До 40 кг · Нагрузка 260 · Drag 87 · Подсечка ×2.3', descEn:'Up to 40 kg · Load 260 · Drag 87 · Hookset ×2.3'},
|
||
];
|
||
const LURES=[
|
||
{id:0, name:'Простой крючок', nameEn:'Simple Hook', icon:'🪝', cost:0, cast:1.00, bite:1.0, rare:0.0, size:0.00, desc:'Надёжно, но рыба не в восторге', descEn:'Reliable, but the fish are not thrilled'},
|
||
{id:1, name:'Червь', nameEn:'Worm', icon:'🪱', cost:625, cast:1.00, bite:1.3, rare:0.2, size:0.12, desc:'Клёв на 30% чаще, шанс крупной выше', descEn:'30% more bites, better chance of big fish'},
|
||
{id:2, name:'Воблер', nameEn:'Wobbler', icon:'🐟', cost:3000, cast:1.15, bite:1.2, rare:0.8, size:0.28, desc:'Дальше бросок, приманивает крупных', descEn:'Farther casts, attracts big fish'},
|
||
{id:3, name:'Золотая блёстка', nameEn:'Gold Spinner', icon:'✨', cost:11250, cast:1.20, bite:1.4, rare:2.0, size:0.50, desc:'Лучшее, что есть. К ней плывут легенды', descEn:'The best there is — legends come to it'},
|
||
];
|
||
/* M5: апгрейды снастей — уровни 1..4 (макс). id 0 (база 0) — фиксированные цены
|
||
переходов 1→2, 2→3, 3→4; снасти id>0 — доля UPG_MULT от их базовой стоимости */
|
||
const ROD_UPG_COST=[70,210,500];
|
||
const LURE_UPG_COST=[60,160,400];
|
||
const UPG_MULT=[0.2,0.6,1.2]; // доля базы: →2 ур ×0.2, →3 ур ×0.6, →4 ур ×1.2
|
||
/* Что даёт следующий уровень (индекс = текущий уровень − 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'];
|
||
/* Y5: i18n — EN-версии подписей уровней (индексы те же) */
|
||
const ROD_UPG_NEXT_EN=['flashlight (night), line ×1.15','drag ×1.15, reel ×1.10','line ×1.15 more, big fish +0.10'];
|
||
const LURE_UPG_NEXT_EN=['bites ×1.15','rare +1.0','big fish +0.15'];
|
||
/* Цена перехода снасти i на следующий уровень (lv — текущий 1..3): id 0 — фикс, id>0 — база×множитель */
|
||
function upgCost(type,i,lv){
|
||
if(i===0) return (type==='rod'?ROD_UPG_COST:LURE_UPG_COST)[lv-1];
|
||
const base=(type==='rod'?RODS:LURES)[i].cost;
|
||
return Math.round(base*UPG_MULT[lv-1]/10)*10;
|
||
}
|
||
|
||
/* Этап 4: скины лодки и поплавка — чистая косметика (coin-sink), в лавке вкладка «Скины» */
|
||
const BOAT_SKINS=[
|
||
{id:0, name:'Обычная', nameEn:'Classic', cost:0},
|
||
{id:1, name:'Красная лодка', nameEn:'Red Boat', cost:800},
|
||
{id:2, name:'Лодка-домик', nameEn:'Boat House', cost:2000}
|
||
];
|
||
const BOBBER_SKINS=[
|
||
{id:0, name:'Обычный', nameEn:'Classic', cost:0},
|
||
{id:1, name:'Красный', nameEn:'Red', cost:500},
|
||
{id:2, name:'Неоновый', nameEn:'Neon', cost:1500}
|
||
];
|
||
|
||
/* =========================================================
|
||
СОХРАНЕНИЕ
|
||
========================================================= */
|
||
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,
|
||
boatSkins:[0],boatSkin:0,bobberSkins:[0],bobberSkin:0,
|
||
locations:[0],location:0,
|
||
kgTotal:0,nightCatch:false,wxRainV:0,wxFog:0}; // Этап 4: скины; Этап 5: локации; Этап 22: счётчики ачивок (кг/ночь/погода)
|
||
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,
|
||
boatSkins:[0],boatSkin:0,bobberSkins:[0],bobberSkin:0,
|
||
locations:[0],location:0,
|
||
kgTotal:0,nightCatch:false,wxRainV:0,wxFog:0}; // Этап 4: скины; Этап 5: локации; Этап 22: счётчики ачивок
|
||
}
|
||
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;
|
||
}
|
||
// миграция M6: счётчики вариантов sv (silver) и rb (rainbow)
|
||
for(const k in save.caught){
|
||
const c=save.caught[k];
|
||
if(c&&typeof c==='object'){
|
||
if(typeof c.sv!=='number') c.sv=0;
|
||
if(typeof c.rb!=='number') c.rb=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;
|
||
// Этап 4: скины лодки/поплавка — дефолты для старых сейвов и чинка битых значений
|
||
if(!save.boatSkins||!Array.isArray(save.boatSkins)) save.boatSkins=[0];
|
||
if(save.boatSkins.indexOf(0)<0) save.boatSkins.push(0); // бесплатный скин 0 гарантирован (битый сейв)
|
||
if(!save.bobberSkins||!Array.isArray(save.bobberSkins)) save.bobberSkins=[0];
|
||
if(save.bobberSkins.indexOf(0)<0) save.bobberSkins.push(0);
|
||
if(typeof save.boatSkin!=='number'||save.boatSkin<0||save.boatSkin>2) save.boatSkin=0;
|
||
if(typeof save.bobberSkin!=='number'||save.bobberSkin<0||save.bobberSkin>2) save.bobberSkin=0;
|
||
if(save.boatSkins.indexOf(save.boatSkin)<0) save.boatSkin=0; // экипированного нет в купленных (битый сейв)
|
||
if(save.bobberSkins.indexOf(save.bobberSkin)<0) save.bobberSkin=0;
|
||
// Этап 5: локации (Река/Озёра) — дефолты для старых сейвов и чинка битых значений (паттерн скинов)
|
||
if(!save.locations||!Array.isArray(save.locations)) save.locations=[0];
|
||
if(save.locations.indexOf(0)<0) save.locations.push(0); // родная Река гарантирована (битый сейв)
|
||
if(typeof save.location!=='number'||save.location<0||save.location>1) save.location=0;
|
||
if(save.locations.indexOf(save.location)<0) save.location=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); }
|
||
|
||
/* =========================================================
|
||
M8: ПОГОДА — ДОЖДЬ И ТУМАН
|
||
========================================================= */
|
||
/* Случайный разброс до следующего события (сек) */
|
||
function weatherGapNext(){ return WEATHER_GAP_MIN+Math.random()*(WEATHER_GAP_MAX-WEATHER_GAP_MIN); }
|
||
/* Эффективная погода: тест-форс имеет приоритет (срабатывает синхронно, без ожидания кадра),
|
||
иначе естественное состояние. Единственная точка чтения для эффектов/визуала/HUD */
|
||
function weatherActive(){ return forceWeather||weather; }
|
||
/* Таймер — в реальном времени (как DAY_LENGTH в frame): между событиями разброс WEATHER_GAP_*,
|
||
само событие длится WEATHER_DURATION. При forceWeather погода активна всегда — таймеры не гоняем */
|
||
function updateWeather(dt){
|
||
if(forceWeather) return; // форс: таймеры не гоняем, weatherT не тикает
|
||
if(weather){
|
||
weatherT-=dt;
|
||
if(weatherT<=0){
|
||
weather=null; weatherGap=weatherGapNext();
|
||
toast(t('wx_clear'));
|
||
}
|
||
}else{
|
||
weatherGap-=dt;
|
||
if(weatherGap<=0){
|
||
weather=Math.random()<0.5?'rain':'fog'; // rain/fog 50/50
|
||
weatherT=WEATHER_DURATION;
|
||
// gap следующего события не роллим здесь: его задаст ветка конца события
|
||
toast(t('wx_'+weather)); // Y5: i18n — тост погоды из T (RU/EN)
|
||
}
|
||
}
|
||
}
|
||
/* Пилюля #weather в HUD рядом с #coins: иконка + остаток секунд («🌧️ 97с»);
|
||
при форсе показываем полную длительность (событие «всегда активно»). Обновляется по таймеру;
|
||
сравниваем ЦЕЛИК text (смена иконки rain↔fog не меняет секунд) */
|
||
let weatherHudTxt='';
|
||
function updateWeatherHud(){
|
||
const el=$('weather');
|
||
const id=weatherActive();
|
||
if(!id){
|
||
if(!el.classList.contains('hidden')) el.classList.add('hidden');
|
||
weatherHudTxt='';
|
||
return;
|
||
}
|
||
if(el.classList.contains('hidden')) el.classList.remove('hidden');
|
||
const sec=forceWeather?WEATHER_DURATION:Math.max(0,Math.ceil(weatherT));
|
||
const txt=WEATHER[id].icon+' '+sec+t('sec_short'); // Y5: i18n — «с»/«s»
|
||
if(txt!==weatherHudTxt){
|
||
weatherHudTxt=txt;
|
||
el.textContent=txt;
|
||
}
|
||
}
|
||
|
||
/* =========================================================
|
||
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_NAMES_EN={carass:'Crucian carp',perch:'Perch',bream:'Bream'}; // Y5: i18n — EN-имена для квестов
|
||
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){
|
||
if(LANG==='en'){ // Y5: i18n — EN-ветка (RU-ветка ниже — побуквенно без изменений)
|
||
switch(q.tpl){
|
||
case 'catch_species': return 'Catch '+q.goal+' '+QUEST_NAMES_EN[q.param];
|
||
case 'earn_coins': return 'Earn '+q.goal+' coins today';
|
||
case 'catch_weight': return 'Catch a fish from '+q.param+' kg';
|
||
case 'casts': return 'Make '+q.goal+' casts today';
|
||
case 'catch_rarity': return 'Catch a '+trRarity(q.param)+'+ fish';
|
||
case 'catch_shiny': return 'Catch a golden fish';
|
||
}
|
||
return '?';
|
||
}
|
||
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 'Поймай золотую рыбу'; // M6: «shiny» = gold-вариант
|
||
}
|
||
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(t('shield'));
|
||
}else{
|
||
save.streak.days=1;
|
||
}
|
||
save.streak.last=today;
|
||
if(!fresh){
|
||
const coins=STREAK_REWARDS[streakIdx(save.streak.days)-1];
|
||
save.coins+=coins;
|
||
toast(tt('streak_day',{d:save.streak.days,c: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(tt('quest_done',{text:questText(q),r: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; }
|
||
/* Y5: i18n — у достижений nameEn/descEn (RU name/desc — побуквенно те же) */
|
||
const ACHIEVEMENTS=[
|
||
{id:'first_catch', name:'Первый улов', nameEn:'First Catch', desc:'Поймай первую рыбу', descEn:'Catch your first fish', icon:'🐟', bonus:{coinPct:0.01}, check:s=>SPECIES.some(sp=>caughtN(s,sp.id)>0)},
|
||
{id:'casts_10', name:'Разминка', nameEn:'Warm-up', desc:'Сделай 10 забросов', descEn:'Make 10 casts', icon:'🎣', bonus:{coinPct:0.01}, check:s=>s.casts>=10},
|
||
{id:'casts_100', name:'Моряк', nameEn:'Sailor', desc:'Сделай 100 забросов', descEn:'Make 100 casts', icon:'⛵', bonus:{coinPct:0.01}, check:s=>s.casts>=100},
|
||
{id:'earned_1000', name:'Первая тысяча', nameEn:'First Thousand', desc:'Заработай 1000 монет', descEn:'Earn 1,000 coins', icon:'🪙', bonus:{coinPct:0.01}, check:s=>s.earned>=1000},
|
||
{id:'earned_10000', name:'Рыбацкий капитал', nameEn:'Fishing Capital', desc:'Заработай 10 000 монет', descEn:'Earn 10,000 coins', icon:'💰', bonus:{coinPct:0.02}, check:s=>s.earned>=10000},
|
||
{id:'gear_max', name:'Максимальная снасть', nameEn:'Max Gear', desc:'Улучши снасть до 4 уровня', descEn:'Upgrade a gear to level 4', icon:'⚒️', bonus:{coinPct:0.01}, check:s=>Object.values(s.rodLv||{}).some(v=>v>=4)||Object.values(s.lureLv||{}).some(v=>v>=4)}, // Этап 22: M5 апгрейды
|
||
{id:'titan', name:'Титан', nameEn:'Titan', desc:'Купи удочку «Титан»', descEn:'Buy the Titanium rod', icon:'💪', bonus:{coinPct:0.02}, check:s=>(s.rods||[]).indexOf(3)>=0},
|
||
{id:'species_3', name:'Ботаник', nameEn:'Botanist', desc:'Поймай 3 вида рыб', descEn:'Catch 3 fish species', icon:'📖', bonus:{coinPct:0.01}, check:s=>SPECIES.filter(sp=>caughtN(s,sp.id)>0).length>=3},
|
||
{id:'species_8', name:'Энциклопедия', nameEn:'Encyclopedia', desc:'Поймай все виды', descEn:'Catch all species', icon:'🏆', bonus:{coinPct:0.02}, check:s=>SPECIES.every(sp=>caughtN(s,sp.id)>0)}, // Этап 5: теперь все 11 видов (id старый сейв)
|
||
{id:'rare_fish', name:'Редкий гость', nameEn:'Rare Guest', desc:'Поймай рыбу редкости «Редкая»+ ', descEn:'Catch a Rare+ fish', icon:'⭐', bonus:{coinPct:0.01}, check:s=>SPECIES.some(sp=>sp.rarity>=2&&caughtN(s,sp.id)>0)},
|
||
{id:'legendary_fish',name:'Легенда', nameEn:'Legend', desc:'Поймай рыбу редкости «Легендарная»', descEn:'Catch a Legendary fish', icon:'👑',bonus:{coinPct:0.02}, check:s=>SPECIES.some(sp=>sp.rarity>=4&&caughtN(s,sp.id)>0)},
|
||
{id:'first_shiny', name:'Золотая жила', nameEn:'Gold Rush', desc:'Поймай золотую рыбу', descEn:'Catch a golden fish', icon:'✨', bonus:{coinPct:0.01}, check:s=>SPECIES.some(sp=>{const c=s.caught&&s.caught[sp.id]; return c&&c.sn>0;})},
|
||
{id:'first_silver', name:'Серебро', nameEn:'Silver', desc:'Поймай серебряную рыбу', descEn:'Catch a silver fish', icon:'🥈', bonus:{coinPct:0.01}, check:s=>SPECIES.some(sp=>{const c=s.caught&&s.caught[sp.id]; return c&&(c.sv||0)>0;})}, // Этап 22: M6 варианты
|
||
{id:'first_rainbow',name:'Радужный улов', nameEn:'Rainbow Catch', desc:'Поймай радужную рыбу', descEn:'Catch a rainbow fish', icon:'🌈', bonus:{coinPct:0.02}, check:s=>SPECIES.some(sp=>{const c=s.caught&&s.caught[sp.id]; return c&&(c.rb||0)>0;})},
|
||
{id:'full_variants',name:'Полная коллекция', nameEn:'Full Collection', desc:'Поймай все виды во всех вариантах', descEn:'Catch every species in every variant', icon:'🌈',bonus:{coinPct:0.05}, check:s=>SPECIES.every(sp=>{const c=s.caught&&s.caught[sp.id]; return c&&c.n>0&&(c.sv||0)>0&&(c.sn||0)>0&&(c.rb||0)>0;})},
|
||
{id:'weight_10', name:'Трофей', nameEn:'Trophy', desc:'Поймай рыбу 10+ кг', descEn:'Catch a fish of 10+ kg', icon:'⚖', bonus:{windowS:0.05}, check:s=>SPECIES.some(sp=>caughtBest(s,sp.id)>=10)},
|
||
{id:'weight_20', name:'Монстр глубин', nameEn:'Deep Monster', desc:'Поймай рыбу 20+ кг', descEn:'Catch a fish of 20+ kg', icon:'🐋', bonus:{line:5}, check:s=>SPECIES.some(sp=>caughtBest(s,sp.id)>=20)},
|
||
{id:'weight_1000', name:'Тонна рыбы', nameEn:'A Ton of Fish', desc:'Поймай рыбы на 1000+ кг', descEn:'Catch 1,000+ kg of fish', icon:'🚢', bonus:{coinPct:0.02}, check:s=>(s.kgTotal||0)>=1000}, // Этап 22
|
||
{id:'night_first', name:'Ночной клёв', nameEn:'Night Bite', desc:'Поймай рыбу ночью', descEn:'Catch a fish at night', icon:'🌙', bonus:{coinPct:0.01}, check:s=>s.nightCatch===true},
|
||
{id:'rain_variant', name:'Дождливый везунчик', nameEn:'Rainy Lucky Guy', desc:'Поймай особую рыбу в дождь', descEn:'Catch a special fish in the rain', icon:'🌧️', bonus:{coinPct:0.01}, check:s=>(s.wxRainV||0)>0}, // Этап 22: M8 погода
|
||
{id:'fog_catch', name:'Туманный улов', nameEn:'Foggy Catch', desc:'Поймай рыбу в тумане', descEn:'Catch a fish in the fog', icon:'🌫️', bonus:{coinPct:0.01}, check:s=>(s.wxFog||0)>0},
|
||
{id:'snaps_10', name:'Упёртый', nameEn:'Stubborn', desc:'10 обрывов шнура', descEn:'Snap the line 10 times', icon:'💥', bonus:{coinPct:0.01}, check:s=>s.snaps>=10},
|
||
{id:'snaps_50', name:'Хроники обрывов', nameEn:'Snap Chronicles', desc:'50 обрывов шнура', descEn:'Snap the line 50 times', icon:'📜', bonus:{coinPct:0.01}, check:s=>s.snaps>=50},
|
||
{id:'streak_7', name:'Семёрка', nameEn:'Seven', desc:'Стрик 7 дней', descEn:'7-day streak', icon:'🔥', bonus:{coinPct:0.01}, check:s=>s.streak&&s.streak.days>=7},
|
||
{id:'quests_5', name:'Дневник искателя', nameEn:"Seeker's Diary", desc:'Выполни 5 квестов', descEn:'Complete 5 quests', icon:'🎯', bonus:{coinPct:0.01}, check:s=>s.questsDoneTotal>=5},
|
||
{id:'skin_first', name:'Красуйся', nameEn:'Dress Up', desc:'Купи скин лодки или поплавка', descEn:'Buy a boat or bobber skin', icon:'🎨', bonus:{coinPct:0.01}, check:s=>(s.boatSkins||[]).length>1||(s.bobberSkins||[]).length>1}, // Этап 22: M-skins
|
||
{id:'skins_all', name:'Стилист', nameEn:'Stylist', desc:'Собери все скины лодки и поплавка', descEn:'Collect all boat and bobber skins', icon:'🎭',bonus:{coinPct:0.02}, check:s=>BOAT_SKINS.every(b=>(s.boatSkins||[]).indexOf(b.id)>=0)&&BOBBER_SKINS.every(b=>(s.bobberSkins||[]).indexOf(b.id)>=0)},
|
||
{id:'lake_first', name:'Новые берега', nameEn:'New Shores', desc:'Открой локацию «Озёра»', descEn:'Unlock the Lakes location', icon:'🗺️', bonus:{coinPct:0.02}, check:s=>(s.locations||[]).indexOf(1)>=0}, // Этап 22: локации
|
||
{id:'boss_first', name:'Босс-рыбак', nameEn:'Boss Angler', desc:'Победи Осётра-босса', descEn:'Defeat the Sturgeon boss', 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');
|
||
if(a.id==='full_variants'){ save.coins+=5000; updateHUD(); } // M6: разовые 5000 за полную коллекцию вариантов
|
||
toast(tt('ach_toast',{name:trName(a)})+(a.id==='full_variants'?' +5000 🪙':''));
|
||
}
|
||
}
|
||
if(changed) persist();
|
||
}
|
||
function bonusText(b){
|
||
if(b.coinPct) return tt('bonus_coin',{v:(b.coinPct*100)});
|
||
if(b.windowS) return tt('bonus_window',{v:b.windowS});
|
||
if(b.line) return tt('bonus_line',{v: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(); initRain();
|
||
}
|
||
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 pauseOpen=false; // Y6: оверлей паузы (треб. 6.3) открыт — гард в YG.onResume
|
||
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 castDist=0; // дальность заброса 0..0.97 (визуал: глубина крючка оснастки)
|
||
let hookPos={x:0,y:0}; // позиция крючка (визуал; пригодится lane поклёвки/вываживания)
|
||
let biteTimer=0, biteT=0, biteFish=null, rippleTimer=0;
|
||
let reel=null;
|
||
let snapT=0, shake=0;
|
||
let stateBoss=false, bossPhase=0; // M4: босс-бой (Осётр-босс, 3 фазы)
|
||
/* M8: погода — активное событие и таймеры (в реальном времени, как день/ночь) */
|
||
let weather=null; // 'rain'/'fog'/null — активное погодное событие (естественное состояние)
|
||
let weatherT=0; // M8: остаток секунд активного события
|
||
let weatherGap=WEATHER_GAP_MIN+Math.random()*(WEATHER_GAP_MAX-WEATHER_GAP_MIN); // M8: сек до следующего события
|
||
let ambient=[]; // фоновые рыбы
|
||
let clouds=[], sparkles=[];
|
||
let rain=[]; // M8: частицы дождя (визуал)
|
||
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)});
|
||
}
|
||
}
|
||
/* M8: частицы дождя (визуал) — ~40 косых полосок; обновление по dt в updateDecor, пересчёт по высоте */
|
||
function initRain(){
|
||
rain=[];
|
||
for(let i=0;i<40;i++){
|
||
rain.push({x:rand(-40,W+40), y:rand(-H,H), v:rand(520,760)});
|
||
}
|
||
}
|
||
|
||
/* =========================================================
|
||
УТИЛИТЫ
|
||
========================================================= */
|
||
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: ночь/вечер — ночная таблица, иначе дневная (жёсткий переключатель)
|
||
// Этап 5: локация — Река (8 видов) или Озёра (все 11); логика ролла по весам без изменений
|
||
const lake=save.location===1;
|
||
const night=(dayPhase==='night'||dayPhase==='evening');
|
||
const tbl=lake?(night?LAKE_SPECIES_NIGHT:LAKE_SPECIES):(night?RIVER_SPECIES_NIGHT:RIVER_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;
|
||
if(weatherActive()==='fog') p*=WEATHER.fog.biteMult; // M8: туман — клёв чаще ×1,5
|
||
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);
|
||
castDist=dist; // запоминаем дальность (визуал: глубина крючка оснастки)
|
||
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(bobber.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){
|
||
fishEscaped(sp);
|
||
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?t('snap_big'):t('snap'))+' '+sp.emoji+' '+trName(sp)+' '+reel.w.toFixed(1)+' '+t('kg'));
|
||
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(tt('boss_phase',{p:bossPhase}));
|
||
}else{
|
||
catchFish(sp);
|
||
}
|
||
}else if(reel.d>=reel.d0*1.45){
|
||
// M4: босс тоже может уйти — босс-бой окончен
|
||
fishEscaped(sp);
|
||
}
|
||
}
|
||
function fishEscaped(sp){
|
||
stateBoss=false; bossPhase=0;
|
||
toast(t('escaped')+' '+sp.emoji+' '+trName(sp)+' '+reel.w.toFixed(1)+' '+t('kg'));
|
||
state='WAIT'; biteTimer=rand(5,10)/lure().bite;
|
||
sfx('escape');
|
||
}
|
||
function catchFish(sp){
|
||
const w=reel.w;
|
||
const boss=!!reel.boss; // M4: пойман Осётр-босс
|
||
const variant=boss?VARIANTS[0]:rollVariant(); // M6: вариант улова (босс — всегда normal, без сложения с ×5)
|
||
const shiny=variant.id==='gold'; // M6: семантика «shiny» = gold-вариант (квест, счётчик sn)
|
||
const b=achBonus();
|
||
const mult=variant.mult*(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,sv:0,rb: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),
|
||
sv:(prev.sv||0)+(variant.id==='silver'?1:0),
|
||
rb:(prev.rb||0)+(variant.id==='rainbow'?1:0)
|
||
};
|
||
if(boss){ // M4: босс побеждён
|
||
save.bossBeaten=true; save.bossCount++;
|
||
toast(tt('boss_defeated',{v: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); // M6: «shiny-рыба» = gold-вариант
|
||
questHit('earn_coins',()=>true,val); // прогресс — монеты улова (с бонусами)
|
||
// Этап 22: счётчики достижений — общий вес, ночной улов, уловы в дождь/туман
|
||
save.kgTotal=(save.kgTotal||0)+w;
|
||
if(dayPhase==='night') save.nightCatch=true;
|
||
const wx=weatherActive();
|
||
if(wx==='rain'&&variant.id!=='normal') save.wxRainV=(save.wxRainV||0)+1; // босс всегда normal — не заходит
|
||
if(wx==='fog') save.wxFog=(save.wxFog||0)+1;
|
||
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:(variant.id!=='normal'?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:(variant.id!=='normal'&&i%3===0)?'255,246,190':'255,215,94'});
|
||
}
|
||
reel=null; stateBoss=false; bossPhase=0; state='CARD';
|
||
showCard(sp,w,val,isRecord,variant,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(t('night_light')); nightNoLightToast=true; }
|
||
}else if(forceBite||Math.random()<biteChance()){
|
||
startBite();
|
||
}else{
|
||
// Спринт 4: пустой клёв — поплавок молчит, попробуем ещё раз
|
||
biteTimer=rand(2.5,5)/lure().bite;
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
case 'APPROACH':
|
||
updateBiteFishIn(dt);
|
||
bobber.y=waterY-3+Math.sin(time*2)*1.5+Math.sin(time*14)*1.5*Math.min(1,biteFish?biteFish.t:0); // предвестники: дрожание, сильнее по мере приближения рыбы
|
||
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+9+Math.sin(time*22)*3; // топление: глубже и резче (синхронно с дёрганием рыбы у крючка)
|
||
if(Math.random()<0.2) addRipple(bobber.x,waterY+3,26);
|
||
if(biteT<=0){
|
||
if(biteFish) biteFish.mode='out';
|
||
state='WAIT'; biteTimer=rand(5,11)/lure().bite;
|
||
sfx('escape'); toast(t('escaped_bite'));
|
||
}
|
||
break;
|
||
case 'REEL':
|
||
updateReel(dt);
|
||
if(state==='REEL'&&reel){
|
||
const fp=fishReelPos();
|
||
let tx,ty;
|
||
if(reel.holding){
|
||
// натянуто: поплавок на пересечении прямой tip→fish с поверхностью (tip–поплавок–рыба коллинеарны, fp.y>waterY>tip.y)
|
||
const k=(waterY-tip.y)/(fp.y-tip.y);
|
||
tx=tip.x+(fp.x-tip.x)*k; ty=waterY-2;
|
||
}else{
|
||
// отпускание: провис, поплавок едет за рыбой с отставанием (покачивание на поверхности)
|
||
tx=castTo.x+(fp.x-castTo.x)*0.55; ty=waterY-2+Math.sin(time*9)*2;
|
||
}
|
||
const sm=Math.min(1,dt*8); // сглаживание, чтобы не телепортировалось
|
||
bobber.x+=(tx-bobber.x)*sm;
|
||
bobber.y+=(ty-bobber.y)*sm;
|
||
if(Math.random()<0.25) addRipple(bobber.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;
|
||
}
|
||
}
|
||
function updateBiteFishIn(dt){
|
||
biteFish.t+=dt/0.45;
|
||
const t=Math.min(1,biteFish.t);
|
||
const e=t*t*(3-2*t);
|
||
// рыба плывёт диагональю от поверхности вниз к крючку/наживке (hookPos — кадр прошлого, паттерн как раньше)
|
||
biteFish.x=biteFish.sx+(hookPos.x-biteFish.sx)*e;
|
||
biteFish.y=biteFish.sy+(hookPos.y-biteFish.sy)*e;
|
||
biteFish.dir=hookPos.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; }
|
||
// M8: дождь — полоски летят вниз-влево, ушедшие за нижний край возвращаются сверху
|
||
if(weatherActive()==='rain'){
|
||
for(const dr of rain){
|
||
dr.y+=dr.v*dt; dr.x-=dr.v*0.3*dt;
|
||
if(dr.y>H+20){ dr.y=rand(-120,-20); dr.x=rand(-40,W+40); }
|
||
if(dr.x<-60) dr.x=W+40;
|
||
}
|
||
}
|
||
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);
|
||
$('btnPause').classList.toggle('hidden',!(state!=='MENU'&&!uiOpen&&!pauseOpen)); // Y6: пауза — вне меню и без оверлеев
|
||
}
|
||
|
||
/* =========================================================
|
||
ОТРИСОВКА
|
||
========================================================= */
|
||
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();
|
||
// градиент воды: день ↔ ночь, плавно (визуал); Этап 5: базовые цвета по локации (LOCATIONS)
|
||
const w=LOCATIONS[Math.max(0,Math.min(1,save.location|0))].water; // clamp — миграция гарантирует 0..1
|
||
const top=hexLerp(w[0],w[3],nm), bot=hexLerp(w[2],w[5],nm);
|
||
const g=ctx.createLinearGradient(0,waterY,0,H);
|
||
g.addColorStop(0,top); g.addColorStop(0.55,hexLerp(w[1],w[4],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();
|
||
// лодка (Этап 4: скины — градиент корпуса и кромка по save.boatSkin)
|
||
const skin=Math.max(0,Math.min(2,save.boatSkin|0));
|
||
const bcols=[['#c9773c','#96521f','#8a4519'],['#c0392b','#7b241c','#6e221a'],['#5d6d7e','#34495e','#2c3a47']][skin];
|
||
const bg=ctx.createLinearGradient(bx-32,by,bx+32,by+15);
|
||
bg.addColorStop(0,bcols[0]); bg.addColorStop(1,bcols[1]);
|
||
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=bcols[2]; ctx.fillRect(bx-32,by,64,4);
|
||
// «Лодка-домик»: каюта на корме корпуса (над линией борта) с ярко-жёлтым окном (визуал)
|
||
if(skin===2){
|
||
ctx.fillStyle='#2c3a47';
|
||
ctx.fillRect(bx-31,by-11,16,11);
|
||
ctx.fillStyle='rgba(255,220,100,0.9)';
|
||
ctx.fillRect(bx-28,by-9,9,5);
|
||
}
|
||
// рыбак
|
||
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(sink){
|
||
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();
|
||
// Этап 4: скины — поплавок по save.bobberSkin (0 обычный, 1 красный, 2 неоновый)
|
||
const skin=Math.max(0,Math.min(2,save.bobberSkin|0));
|
||
const bot=['#fff','#e74c3c','#aaff00'][skin];
|
||
const top=['#e74c3c','#e74c3c','#aaff00'][skin];
|
||
if(skin===2){ ctx.save(); ctx.shadowColor='rgba(170,255,0,0.8)'; ctx.shadowBlur=10; } // неоновое свечение — обязательно save/restore, чтобы не перетечь на другие элементы
|
||
ctx.fillStyle=bot;
|
||
ctx.beginPath(); ctx.arc(x,y,5.5,0,7); ctx.fill();
|
||
ctx.fillStyle=top;
|
||
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=skin===2?'#77cc00':'rgba(255,255,255,0.9)'; ctx.lineWidth=1.5;
|
||
ctx.beginPath(); ctx.arc(x,y,5.5,0,7); ctx.stroke();
|
||
if(skin===2) ctx.restore();
|
||
// топление (визуал, lane 2): в BITE нижняя половина уходит под воду — полупрозрачная кромка воды поверх низа поплавка
|
||
// рисуем ВНЕ save/restore неона, чтобы свечение не перетекло на слой воды
|
||
if(sink){
|
||
ctx.fillStyle='rgba(10,30,60,0.35)';
|
||
ctx.beginPath(); ctx.arc(x,y,5.6,0,Math.PI); ctx.closePath(); ctx.fill(); // нижняя половина под водой
|
||
ctx.strokeStyle='rgba(150,200,235,0.55)'; ctx.lineWidth=1; // тонкая светлая линия уровня воды
|
||
ctx.beginPath(); ctx.moveTo(x-5.6,y); ctx.lineTo(x+5.6,y); ctx.stroke();
|
||
}
|
||
}
|
||
function hookY(){
|
||
// глубина крючка на середине воды: доля глубины 0.42..0.54 по дальности заброса (визуал)
|
||
return waterY+(H-waterY)*(0.42+castDist*0.12);
|
||
}
|
||
function drawRig(){
|
||
// Живой оснастки (lane 1): поводок от поплавка вниз, крючок и наживка.
|
||
// Только визуал — механика/баланс не трогается. hookPos экспортируем для следующих шагов.
|
||
const hy=hookY(); // глубина крючка (середина воды, доля 0.42..0.54 по дальности)
|
||
const hx=bobber.x+Math.sin(time*2.2)*3; // лёгкое покачивание в противофазе к поплавку
|
||
hookPos={x:hx,y:hy};
|
||
// вертикальный бледный поводок (бледнее надводной лески 0.8 → 0.35)
|
||
ctx.strokeStyle='rgba(230,240,255,0.35)'; ctx.lineWidth=1;
|
||
ctx.beginPath(); ctx.moveTo(bobber.x,bobber.y); ctx.lineTo(hx,hy); ctx.stroke();
|
||
// крючок: дуга + жало (серый ~5px)
|
||
ctx.strokeStyle='#cfd6dd'; ctx.lineWidth=1.4; ctx.lineCap='round';
|
||
ctx.beginPath(); ctx.arc(hx,hy+4,4.5,Math.PI*0.1,Math.PI*1.6); ctx.stroke();
|
||
const eb=Math.PI*1.6, ex=hx+4.5*Math.cos(eb), ey=(hy+4)+4.5*Math.sin(eb);
|
||
ctx.beginPath(); ctx.moveTo(ex,ey); ctx.lineTo(ex+2.5,ey-2); 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;
|
||
}
|
||
let sag;
|
||
if(state==='REEL'){
|
||
sag=reel.holding?0:(reel.phase==='pull'?16:12); // holding — натянуто (прямая tip→поплавок); отпускание — провис (рывок больше)
|
||
}else{
|
||
sag=18+Math.sin(time*2)*3;
|
||
}
|
||
drawLineTo(bobber.x,bobber.y,sag);
|
||
drawBobber(state==='BITE'?1:0); // топление: в BITE нижняя половина под водой, в WAIT/APPROACH/REEL — нет
|
||
if(state==='REEL'&&reel){ // подводный сегмент лески bobber→рыба (бледнее надводной), чтобы леска шла к рыбе
|
||
const fp=fishReelPos();
|
||
ctx.strokeStyle='rgba(230,240,255,0.35)'; ctx.lineWidth=1;
|
||
ctx.beginPath(); ctx.moveTo(bobber.x,bobber.y); ctx.lineTo(fp.x,fp.y); ctx.stroke();
|
||
}
|
||
if(state==='WAIT'||state==='APPROACH'||state==='BITE') drawRig(); // живая оснастка (в REEL крючок «в рыбе» — не рисуем)
|
||
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 fishReelPos(){
|
||
// lane 3: позиция пойманной рыбы в REEL по прогрессу вываживания — по диагонали от глубины крючка (середина воды) к лодке у поверхности (d0>0 гарантировано)
|
||
const p=Math.max(0,Math.min(1,1-reel.d/reel.d0));
|
||
const pe=p*p*(3-2*p); // лёгкое ease — плавный подход к лодке
|
||
const k=reel.tired?0.35:1; // уставшая рыба вялая — амплитуда покачивания ниже
|
||
const hy=hookY(); // старт пути — точка крючка (середина воды)
|
||
const x=castTo.x+(boatX+24-castTo.x)*pe + Math.sin(time*7*k)*4*k; // сохраняем покачивание
|
||
const y=hy+(waterY+10-hy)*pe + Math.sin(time*5*k)*3*k; // диагональ: из глубины к лодке у поверхности
|
||
return {x:x,y:y};
|
||
}
|
||
function drawHookedFish(){
|
||
if(state!=='REEL'||!reel) return;
|
||
const sp=reel.sp;
|
||
const k=reel.tired?0.35:1; // Спринт 4: уставшая рыба вялая — медленнее и амплитуда ниже
|
||
const fp=fishReelPos();
|
||
const x=fp.x, y=fp.y;
|
||
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(tt('boss_phase_draw',{p:bossPhase}),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();
|
||
// 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();
|
||
}
|
||
// escape: красная заливка поверх синей, слева направо (рыба ускользает)
|
||
if(reel.escape>0.01){
|
||
const ew=(dw-4)*reel.escape;
|
||
ctx.fillStyle='rgba(231,76,60,0.7)';
|
||
rr(dx+2,dy+2,ew,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+12,dy+dh+18);
|
||
ctx.fillText('⚡',dx+32,dy+dh+18);
|
||
// Спринт 4: выносливость — тонкая полоска под дистанцией (затухает к нулю)
|
||
// T2b: начинается после иконок 🐟⚡ (dx+44), чтобы не перекрывать их
|
||
const sdh=5, sdy=dy+dh+3;
|
||
ctx.fillStyle='rgba(0,0,0,0.3)'; rr(dx+44,sdy,dw-44,sdh,2.5); ctx.fill();
|
||
if(reel.st>0.01){
|
||
ctx.fillStyle=reel.tired?'#7d5ba6':'#b07cc6';
|
||
rr(dx+45.5,sdy+1.5,Math.max(1.5,(dw-47)*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(t('reel_pull'),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(t('reel_tired'),W/2,dy-30);
|
||
ctx.shadowBlur=0;
|
||
}else{
|
||
ctx.fillStyle='#7ef0a8';
|
||
ctx.shadowColor='rgba(0,0,0,0.6)'; ctx.shadowBlur=4;
|
||
ctx.fillText(t('reel_hold'),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(t('cast_release'),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(t('menu_charge_touch'),W/2,H-70); // Y5: i18n (текст тот же, что в меню — тач-ветка)
|
||
ctx.shadowBlur=0;
|
||
ctx.globalAlpha=1;
|
||
}
|
||
/* M8: погода — слой в КОНЦЕ draw-цикла: дождь — косые полоски поверх воды и неба,
|
||
туман — полупрозрачный слой поверх сцены (+2 полосы глубже для объёма).
|
||
HUD — DOM, canvas-слой его не скрывает; поплавок/кнопки под 0.28 тумана остаются читаемыми */
|
||
function drawWeather(){
|
||
const id=weatherActive();
|
||
if(!id) return;
|
||
if(id==='rain'){
|
||
ctx.strokeStyle='rgba(180,200,220,0.5)';
|
||
ctx.lineWidth=1.5;
|
||
ctx.beginPath();
|
||
for(const dr of rain){
|
||
ctx.moveTo(dr.x,dr.y);
|
||
ctx.lineTo(dr.x-6,dr.y+20);
|
||
}
|
||
ctx.stroke();
|
||
}else{
|
||
ctx.fillStyle='rgba(200,215,225,0.28)';
|
||
ctx.fillRect(-8,-8,W+16,H+16); // +8px по периметру: слой рисуется внутри shake-транса — без просвета фона при обрыве
|
||
// две полосы с разной прозрачностью и медленным дрейфом — глубина
|
||
const b1=((time*10)%(H+120))-120;
|
||
const b2=H-((time*6)%(H+160));
|
||
ctx.fillStyle='rgba(226,236,244,0.10)';
|
||
ctx.fillRect(0,b1,W,48);
|
||
ctx.fillStyle='rgba(226,236,244,0.07)';
|
||
ctx.fillRect(0,b2,W,70);
|
||
}
|
||
}
|
||
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'){
|
||
if(state==='BITE'){
|
||
// топление (lane 2): рыба дёргается у крючка, синхронно с фазами топления поплавка (time*22)
|
||
drawFishShape(hookPos.x+Math.sin(time*25)*7, hookPos.y+Math.cos(time*22)*5, 12+biteFish.sp.rarity*2.5, Math.sin(time*25)>0?1:-1, biteFish.sp.hue, 0.8, Math.sin(time*8)*0.25);
|
||
}else{
|
||
// APPROACH: подплывает к поплавку как раньше
|
||
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();
|
||
drawWeather(); // M8: погодный слой — в конце draw-цикла
|
||
ctx.restore();
|
||
}
|
||
|
||
/* =========================================================
|
||
Y1: SDK Яндекс Игр
|
||
Обёртка YG — единственная точка SDK. Стаб (file:// без ?sdk=1) —
|
||
no-op + счётчики для тестов. SDK подгружается динамически ТОЛЬКО
|
||
по http(s) (или file://?sdk=1): при чистом file:// скрипт не
|
||
создаётся вообще (иначе 404/ERR_FILE_NOT_FOUND в консоли).
|
||
file://?sdk=1 — относительный sdk.js: работает, если лежит рядом
|
||
с index.html.
|
||
========================================================= */
|
||
const YG={
|
||
sdk:null, active:false, // ysdk или null (стаб)
|
||
paused:false, // глобальная пауза: реклама / скрытая вкладка / платформа
|
||
readySent:false,
|
||
counts:{ready:0,start:0,stop:0,fsAdv:0,rv:0}, // счётчики вызовов (для тестов)
|
||
adWatchdog:null, // Y2: watchdog — SDK не закрыл рекламу (нет onClose/onError) → снять паузу
|
||
_sdkReady(){ if(this.sdk){ try{ this.sdk.features.LoadingAPI&&this.sdk.features.LoadingAPI.ready&&this.sdk.features.LoadingAPI.ready(); }catch(e){} } },
|
||
init(ysdk){
|
||
if(this.active) return; // однократно (апгрейд стаб(null) → реальный SDK позже — разрешён)
|
||
this.sdk=ysdk||null; this.active=!!ysdk;
|
||
if(ysdk){
|
||
try{
|
||
const p=()=>this.onPause(), r=()=>this.onResume();
|
||
ysdk.on('game_api_pause',p);
|
||
ysdk.on('game_api_resume',r);
|
||
}catch(e){}
|
||
}
|
||
if(this.readySent) this._sdkReady(); // SDK доинициализировался после ready()
|
||
},
|
||
ready(){ this.readySent=true; this.counts.ready++; this._sdkReady(); },
|
||
gameplayStart(){ this.counts.start++; if(this.sdk){ try{ this.sdk.features.GameplayAPI&&this.sdk.features.GameplayAPI.start&&this.sdk.features.GameplayAPI.start(); }catch(e){} } },
|
||
gameplayStop(){ this.counts.stop++; if(this.sdk){ try{ this.sdk.features.GameplayAPI&&this.sdk.features.GameplayAPI.stop&&this.sdk.features.GameplayAPI.stop(); }catch(e){} } },
|
||
onPause(){
|
||
if(this.paused) return;
|
||
this.paused=true;
|
||
this.gameplayStop();
|
||
if(AC){ try{ AC.suspend(); }catch(e){} }
|
||
},
|
||
onResume(){
|
||
if(!this.paused) return;
|
||
// резюме не снимается, пока открыта реклама — снимет endAd()
|
||
// (пути сюда: visibilitychange / game_api_resume, а оверлей рекламы ещё открыт)
|
||
if(adShowing) return;
|
||
// Y6: резюме не снимается, пока открыт оверлей паузы — снимут только
|
||
// closePause()/кнопка «В меню» (пути сюда: blur/focus/visibilitychange
|
||
// во время открытой паузы)
|
||
if(pauseOpen) return;
|
||
this.paused=false;
|
||
last=performance.now();
|
||
if(state!=='MENU'&&!uiOpen) this.gameplayStart();
|
||
},
|
||
// Y2: реклама — ТОЛЬКО через SDK Яндекса (треб. 4.1). Стаб: fullscreen —
|
||
// no-op (вернёт false — реклама не показана), rewarded — симуляция успешного
|
||
// просмотра (локальная разработка, all-в-одном-клике флоу).
|
||
// Y2: watchdog — если SDK не доставит onClose/onError (баг SDK, убитый iframe),
|
||
// паузу снимаем сами, иначе игра замрзнет навсегда (треб. 1.14)
|
||
_startAdWatchdog(){
|
||
clearTimeout(this.adWatchdog);
|
||
this.adWatchdog=setTimeout(()=>{ if(adShowing){ console.warn('Yandex SDK: реклама не закрылась (90 с) — снимаю паузу'); endAd(); } },90000);
|
||
},
|
||
showFullscreen(cb){
|
||
this.counts.fsAdv++;
|
||
if(this.sdk){
|
||
try{ this.sdk.adv.showFullscreenAdv({callbacks:cb}); }catch(e){ return false; } // сбой SDK — как стаб: endAd() на месте вызова
|
||
this._startAdWatchdog();
|
||
return true;
|
||
}
|
||
return false; // стаб: реклама не показана
|
||
},
|
||
showRewarded(cb){
|
||
this.counts.rv++;
|
||
if(this.sdk){
|
||
try{ this.sdk.adv.showRewardedVideo({callbacks:cb}); }catch(e){ return false; }
|
||
this._startAdWatchdog(); // стаб-ветка ниже — флоу синхронный, watchdog не ставится
|
||
return true;
|
||
}
|
||
// стаб: симуляция успешного просмотра (локальная разработка)
|
||
try{ cb.onOpen&&cb.onOpen(); cb.onRewarded&&cb.onRewarded(); cb.onClose&&cb.onClose(true); }catch(e){}
|
||
return true;
|
||
},
|
||
};
|
||
// Y2: константы и состояние рекламы. Interstitial — на закрытии карточки улова
|
||
// (логическая пауза, треб. 4.4): первый показ не раньше AD_FIRST_MS сессии,
|
||
// дальше не чаще AD_COOLDOWN_MS. Rewarded «монеты ×2» — на самой карточке
|
||
// (треб. 4.5.1/4.5.2). Реклама не персистится (как погода).
|
||
const AD_FIRST_MS=120000; // первый interstitial не раньше 2 мин сессии
|
||
const AD_COOLDOWN_MS=180000; // между interstitial'ами — 3 мин
|
||
let sessionStartTs=Date.now(); // старт сессии (момент загрузки страницы)
|
||
let lastAdTs=0; // время запуска последнего interstitial
|
||
let adShowing=false; // реклама прямо сейчас (interstitial или rewarded)
|
||
let rvVal=0, rvDone=false; // rewarded: цена улова для бонуса ×2 и флаг «уже получен»
|
||
|
||
/* =========================================================
|
||
Y5: i18n RU/EN — автоопределение языка (треб. 2.14)
|
||
T — таблицы строк: ru — ТЕКУЩИЕ точные тексты, en — перевод.
|
||
Резолюция языка: forceLang (тест-форс) → SDK environment.i18n.lang
|
||
→ ru. Стаб (file:// без SDK) — ВСЕГДА ru. navigator.language
|
||
не используем осознанно (headless en-US сломал бы RU-тесты).
|
||
Блок стоит ДО IIFE-загрузки SDK: стаб-ветка сразу вызывает
|
||
resolveLang(null) → setLang → t(...), так что T должен быть
|
||
инициализирован до этой точки скрипта.
|
||
========================================================= */
|
||
const T={
|
||
ru:{
|
||
title:'Рыбалка',
|
||
menu_title:'🎣 РЫБАЛКА',
|
||
menu_charge_touch:'👆 Держи палец — заряжай бросок',
|
||
menu_charge_mouse:'🖱 Зажми кнопку мыши — заряжай бросок',
|
||
menu_throw:'<b>отпусти</b> — кидай удочку.<br>Дальше бросок = реже и жирнее рыба!',
|
||
menu_bite:'Видишь <b style="color:#ff6b5e">!</b> — быстро жми, чтобы зацепить.<br>При вываживании: тяни, когда рыба отдыхает,<br>отпусти, когда она <b>тянет</b> — иначе лопнет!<br>Не тяни слишком долго — рыба <b>сбегает</b> (красная полоса)!',
|
||
menu_weight:'У каждой рыбы свой <b>вес</b>, продажа — по цене за кг.<br>У удочек есть <b>предел веса</b>: перевесишь — шнур лопнет! <br>Лучшие снасти = больше шансов на трофей 🏆',
|
||
menu_drag:'Не каждый заброс — с клёвом: если молчит — подожди.<br>У удилищ есть <b>тормоз (drag)</b>: рывок сильнее — леска скользит!',
|
||
btn_play:'Играть',
|
||
shop_title:'🛒 Лавка рыбака',
|
||
sec_rods:'🎣 Удилища',
|
||
sec_lures:'🪱 Приманки',
|
||
sec_boat:'Лодка',
|
||
sec_bobber:'Поплавок',
|
||
sec_locations:'Локации',
|
||
sec_streak:'Стрик входа',
|
||
sec_quests:'Квесты дня',
|
||
sec_ach:'🏅 Достижения',
|
||
btn_select:'Выбрать',
|
||
btn_selected:'✓ Выбрано',
|
||
btn_selected_chk:'Выбрано ✓',
|
||
btn_selected_f:'Выбрана ✓',
|
||
btn_buy:'Купить',
|
||
btn_cancel:'Отмена',
|
||
btn_reset:'Сбросить',
|
||
btn_ok:'Отлично!',
|
||
free:'бесплатно',
|
||
lvl:'ур.',
|
||
kg:'кг',
|
||
per_kg:'🪙/кг',
|
||
price_label:'Цена: ',
|
||
locked_desc:'Откроется: улучши «{name}» до 4 ур.',
|
||
gate_toast:'Сначала улучши «{name}» до 4 ур.',
|
||
stats:'Бросков: {casts} · Заработано: {earned} 🪙',
|
||
not_caught:'не пойман',
|
||
max_w:'макс {w} кг',
|
||
card_sub:'{w} кг · {price} 🪙/кг',
|
||
card_record:'🏆 РЕКОРД!',
|
||
card_boss:'🏆 БОСС!',
|
||
card_boss_suffix:'-БОСС',
|
||
rv_btn:'📺 Реклама: ×2 за этот улов',
|
||
rv_done:'✓ Бонус получен',
|
||
boss_defeated:'🏆 БОСС ПОБЕЖДЁН! +{v} 🪙',
|
||
streak_day:'Стрик: день {d} — +{c} 🪙',
|
||
streak_line:'День {d} · далее +{n} 🪙',
|
||
quest_done:'Квест выполнен: {text} +{r} 🪙',
|
||
quests_done_today:'Квесты на сегодня закончились — заходи завтра!',
|
||
shield:'Щит спас стрик!',
|
||
bonus_coin:'+{v}% к монетам',
|
||
bonus_window:'+{v} с к подсечке',
|
||
bonus_line:'+{v} к прочности',
|
||
ach_toast:'🏅 Достижение: {name}',
|
||
bought:'Куплено: {name}!',
|
||
upgraded:'Улучшено: {name} → {lv} ур.!',
|
||
not_enough:'Не хватает монет 🪙',
|
||
welcome_lakes:'Добро пожаловать на Озёра!',
|
||
location_set:'Локация: {name}',
|
||
progress_reset:'Прогресс сброшен 🔄',
|
||
night_light:'Ночью нужен фонарик — улучши удилище (первый апгрейд)',
|
||
escaped_bite:'Ушла… жми быстрее!',
|
||
snap_big:'💥 Рыба крупнее предела удочки!',
|
||
snap:'💥 Линия лопнула!',
|
||
escaped:'Сбежала!',
|
||
boss_phase:'⚠️ Босс рвёт леску! Фаза {p}/3',
|
||
boss_phase_draw:'⚠️ БОСС-РЫБА · ФАЗА {p}/3',
|
||
reel_pull:'⚡ РЫБА ТЯНЕТ — ОТПУСТИ! ⚡',
|
||
reel_tired:'💤 Рыба устала — втягивай!',
|
||
reel_hold:'Держи палец — втягивай!',
|
||
cast_release:'Отпусти — бросок!',
|
||
wx_clear:'☀️ Погода прояснила',
|
||
wx_rain:'Дождь: шанс серебряной/золотой/радужной ×2,5',
|
||
wx_fog:'Туман: клёв чаще ×1,5',
|
||
sec_short:'с',
|
||
reset_title:'🗑️ Сбросить прогресс?',
|
||
reset_text:'Монеты, снасти и коллекция будут стёрты.<br>Это действие <b>нельзя отменить</b>.',
|
||
reset_title_attr:'Сбросить прогресс',
|
||
tab_tackle:'Снасти',
|
||
tab_journal:'Дневник',
|
||
tab_rewards:'Награды',
|
||
tab_ach:'Достижения',
|
||
tab_skins:'Скины',
|
||
tab_locations:'Локации',
|
||
pause_title:'⏸ Пауза',
|
||
pause_resume:'Продолжить',
|
||
pause_menu:'В меню',
|
||
btn_pause_title:'Пауза'
|
||
},
|
||
en:{
|
||
title:'Fishing',
|
||
menu_title:'🎣 FISHING',
|
||
menu_charge_touch:'👆 Hold your finger — charge the cast',
|
||
menu_charge_mouse:'🖱 Hold the mouse button — charge the cast',
|
||
menu_throw:'<b>let go</b> — cast your rod.<br>Farther cast = rarer and bigger fish!',
|
||
menu_bite:'See the <b style="color:#ff6b5e">!</b> — hit it fast to hook the fish.<br>While reeling in: pull when the fish rests,<br>let go when it <b>pulls</b> — or the line snaps!<br>Do not hold too long — the fish <b>escapes</b> (red bar)!',
|
||
menu_weight:'Every fish has its own <b>weight</b>, sold by price per kg.<br>Rods have a <b>weight limit</b>: overload — the line snaps! <br>Better gear = better trophy chances 🏆',
|
||
menu_drag:'Not every cast gets a bite: if it is silent — wait.<br>Rods have a <b>drag</b>: a stronger jerk — the line slips!',
|
||
btn_play:'Play',
|
||
shop_title:"🛒 Angler's Shop",
|
||
sec_rods:'🎣 Rods',
|
||
sec_lures:'🪱 Lures',
|
||
sec_boat:'Boat',
|
||
sec_bobber:'Bobber',
|
||
sec_locations:'Locations',
|
||
sec_streak:'Daily Streak',
|
||
sec_quests:'Daily Quests',
|
||
sec_ach:'🏅 Achievements',
|
||
btn_select:'Select',
|
||
btn_selected:'✓ Selected',
|
||
btn_selected_chk:'Selected ✓',
|
||
btn_selected_f:'Selected ✓',
|
||
btn_buy:'Buy',
|
||
btn_cancel:'Cancel',
|
||
btn_reset:'Reset',
|
||
btn_ok:'Nice!',
|
||
free:'Free',
|
||
lvl:'lvl',
|
||
kg:'kg',
|
||
per_kg:'🪙/kg',
|
||
price_label:'Price: ',
|
||
locked_desc:'Unlocks: upgrade "{name}" to level 4',
|
||
gate_toast:'Upgrade "{name}" to level 4 first',
|
||
stats:'Casts: {casts} · Earned: {earned} 🪙',
|
||
not_caught:'not caught',
|
||
max_w:'max {w} kg',
|
||
card_sub:'{w} kg · {price} 🪙/kg',
|
||
card_record:'🏆 RECORD!',
|
||
card_boss:'🏆 BOSS!',
|
||
card_boss_suffix:'-BOSS',
|
||
rv_btn:'📺 Ad: ×2 for this catch',
|
||
rv_done:'✓ Bonus received',
|
||
boss_defeated:'🏆 BOSS DEFEATED! +{v} 🪙',
|
||
streak_day:'Streak: day {d} — +{c} 🪙',
|
||
streak_line:'Day {d} · next +{n} 🪙',
|
||
quest_done:'Quest done: {text} +{r} 🪙',
|
||
quests_done_today:"Today's quests are done — come back tomorrow!",
|
||
shield:'The shield saved your streak!',
|
||
bonus_coin:'+{v}% to coins',
|
||
bonus_window:'+{v}s hookset window',
|
||
bonus_line:'+{v} line strength',
|
||
ach_toast:'🏅 Achievement: {name}',
|
||
bought:'Bought: {name}!',
|
||
upgraded:'Upgraded: {name} → lvl {lv}!',
|
||
not_enough:'Not enough coins 🪙',
|
||
welcome_lakes:'Welcome to the Lakes!',
|
||
location_set:'Location: {name}',
|
||
progress_reset:'Progress reset 🔄',
|
||
night_light:'Night needs a flashlight — upgrade your rod (first upgrade)',
|
||
escaped_bite:'It got away… hit it faster!',
|
||
snap_big:'💥 Fish bigger than the rod limit!',
|
||
snap:'💥 Line snapped!',
|
||
escaped:'Escaped!',
|
||
boss_phase:'⚠️ The boss is tearing the line! Phase {p}/3',
|
||
boss_phase_draw:'⚠️ BOSS FISH · PHASE {p}/3',
|
||
reel_pull:'⚡ FISH PULLS — LET GO! ⚡',
|
||
reel_tired:'💤 Fish is tired — reel in!',
|
||
reel_hold:'Hold — reel in!',
|
||
cast_release:'Release — cast!',
|
||
wx_clear:'☀️ Weather cleared',
|
||
wx_rain:'Rain: silver/gold/rainbow chance ×2.5',
|
||
wx_fog:'Fog: bites more often ×1.5',
|
||
sec_short:'s',
|
||
reset_title:'🗑️ Reset progress?',
|
||
reset_text:'Coins, gear and the collection will be erased.<br>This action <b>cannot be undone</b>.',
|
||
reset_title_attr:'Reset progress',
|
||
tab_tackle:'Gear',
|
||
tab_journal:'Journal',
|
||
tab_rewards:'Rewards',
|
||
tab_ach:'Achievements',
|
||
tab_skins:'Skins',
|
||
tab_locations:'Locations',
|
||
pause_title:'⏸ Pause',
|
||
pause_resume:'Resume',
|
||
pause_menu:'To menu',
|
||
btn_pause_title:'Pause'
|
||
}
|
||
};
|
||
let LANG='ru';
|
||
let forceLang=null; // Y5: тест-глобал: 'ru'|'en'|null (приоритет в resolveLang)
|
||
function t(key){ const m=T[LANG]||{}; return (key in m)?m[key]:((T.ru&&T.ru[key]!==undefined)?T.ru[key]:key); }
|
||
function tt(key,o){ let s=t(key); if(o){for(const k in o)s=s.split('{'+k+'}').join(o[k]);} return s; }
|
||
function trName(x){ return (LANG==='en'&&x.nameEn)?x.nameEn:x.name; }
|
||
function trDesc(x){ return (LANG==='en'&&x.descEn)?x.descEn:x.desc; }
|
||
function trRarity(i){ return (LANG==='en')?RARITY_NAMES_EN[i]:RARITY_NAMES[i]; }
|
||
/* Y5: подпись следующего уровня снасти в лавке (RU/EN по языку) */
|
||
function upgNextText(type,lv){ return (LANG==='en')?((type==='rod'?ROD_UPG_NEXT_EN:LURE_UPG_NEXT_EN)[lv]):((type==='rod'?ROD_UPG_NEXT:LURE_UPG_NEXT)[lv]); }
|
||
/* Y5: безопасные проверки видимости оверлеев (для setLang) */
|
||
function shopOpenNow(){ const s=$('shop'); return !!(s&&!s.classList.contains('hidden')); }
|
||
function cardOpenNow(){ const c=$('card'); return !!(c&&!c.classList.contains('hidden')); }
|
||
const IS_TOUCH=('ontouchstart' in window)||(navigator.maxTouchPoints>0); // Y5: device-aware текст меню
|
||
/* Y5: меню — заполняется из T (ид btnPlay и класс big сохраняются; узлы НЕ пересоздаются —
|
||
обработчики слушателей живут) */
|
||
function renderMenu(){
|
||
$('menuTitle').textContent=t('menu_title');
|
||
$('menuP1').innerHTML=(IS_TOUCH?t('menu_charge_touch'):t('menu_charge_mouse'))+'<br>'+t('menu_throw');
|
||
$('menuP2').innerHTML=t('menu_bite');
|
||
$('menuP3').innerHTML=t('menu_weight');
|
||
$('menuP4').innerHTML=t('menu_drag');
|
||
$('btnPlay').textContent=t('btn_play');
|
||
}
|
||
/* Y5: статичные подписи вне shopBody (лавка/вкладки/сброс/карточка) — из T */
|
||
function renderStaticUI(){
|
||
$('shopTitle').textContent=t('shop_title');
|
||
$('btnReset').title=t('reset_title_attr');
|
||
$('tabTackle').title=t('tab_tackle');
|
||
$('tabJournal').title=t('tab_journal');
|
||
$('tabRewards').title=t('tab_rewards');
|
||
$('tabAch').title=t('tab_ach');
|
||
$('tabSkins').title=t('tab_skins');
|
||
$('tabLocations').title=t('tab_locations');
|
||
$('resetTitle').textContent=t('reset_title');
|
||
$('resetText').innerHTML=t('reset_text');
|
||
$('btnResetNo').textContent=t('btn_cancel');
|
||
$('btnResetYes').textContent=t('btn_reset');
|
||
$('pauseTitle').textContent=t('pause_title');
|
||
$('btnPauseResume').textContent=t('pause_resume');
|
||
$('btnPauseMenu').textContent=t('pause_menu');
|
||
$('btnPause').title=t('btn_pause_title');
|
||
$('cardRecord').textContent=t('card_record');
|
||
$('cardBoss').textContent=t('card_boss');
|
||
$('btnCardOk').textContent=t('btn_ok');
|
||
$('btnRv').textContent=t('rv_btn');
|
||
}
|
||
/* Y5: пере-подпись открытых элементов карточки улова при смене языка */
|
||
function refreshCardNow(){
|
||
$('cardRecord').textContent=t('card_record');
|
||
$('cardBoss').textContent=t('card_boss');
|
||
$('btnCardOk').textContent=t('btn_ok');
|
||
$('btnRv').textContent=rvDone?t('rv_done'):t('rv_btn');
|
||
}
|
||
function setLang(lang){
|
||
LANG=(lang==='en')?'en':'ru';
|
||
document.documentElement.lang=LANG;
|
||
document.title=t('title');
|
||
renderMenu();
|
||
renderStaticUI();
|
||
if(shopOpenNow())renderShop();
|
||
if(cardOpenNow())refreshCardNow();
|
||
}
|
||
/* Y5: резолюция языка — ДВЕ ветки вызова (стаб IIFE / finish(ysdk)):
|
||
forceLang → SDK i18n.lang → ru (дефолт, в т.ч. стаб) */
|
||
function resolveLang(ysdk){
|
||
let lg=forceLang;
|
||
if(!lg&&ysdk&&ysdk.environment&&ysdk.environment.i18n&&ysdk.environment.i18n.lang)lg=ysdk.environment.i18n.lang;
|
||
setLang(lg||'ru');
|
||
}
|
||
|
||
// Y1: динамическая загрузка SDK (документированный Яндексом вариант):
|
||
// http(s) — /sdk.js, file://?sdk=1 — sdk.js рядом с index.html. Первый сбой
|
||
// — один ретраи, второй — стаб + warn. 3-секундный таймер не финализирует.
|
||
(function(){
|
||
const sdkEnabled=location.protocol!=='file:'||/[?&]sdk=1/.test(location.search);
|
||
if(!sdkEnabled){ YG.init(null); resolveLang(null); return; } // file:// без ?sdk=1 — сразу стаб (язык всегда ru, кроме forceLang), скрипт не создаётся
|
||
const finish=(ysdk)=>{ YG.init(ysdk||null); resolveLang(ysdk); }; // единая точка (защита в init: идемпотентно); Y5: язык из SDK i18n.lang
|
||
let attempt=0, settled=false, slowWarned=false;
|
||
const load=()=>{
|
||
attempt++;
|
||
const s=document.createElement('script');
|
||
s.src=location.protocol==='file:'?'sdk.js':'/sdk.js'; // file:// — относительный путь
|
||
s.async=true;
|
||
s.onload=()=>{
|
||
settled=true;
|
||
if(window.YaGames){ try{ YaGames.init().then(y=>finish(y)).catch(()=>finish(null)); }catch(e){ finish(null); } }
|
||
else finish(null);
|
||
};
|
||
s.onerror=()=>{
|
||
if(attempt<2){ load(); return; } // первый сбой — один ретраи (новый скрипт с тем же src)
|
||
settled=true;
|
||
finish(null);
|
||
console.warn('Yandex SDK: загрузка не удалась, игра в стаб-режиме');
|
||
};
|
||
document.head.appendChild(s);
|
||
};
|
||
load();
|
||
setTimeout(()=>{ // страховка: не финализирует, только одноразовый warn
|
||
if(!settled&&!slowWarned){ slowWarned=true; console.warn('Yandex SDK: загрузка дольше 3 с'); }
|
||
},3000);
|
||
})();
|
||
// Y1/Y6: потеря фокуса (треб. 1.3) — пауза звука/гейма при скрытой вкладке
|
||
// (visibilitychange) И при потере фокуса окна (blur, десктоп/мобил);
|
||
// возврат — visible/focus → YG.onResume (гарды adShowing/pauseOpen внутри)
|
||
document.addEventListener('visibilitychange',()=>{ if(document.hidden) YG.onPause(); else YG.onResume(); });
|
||
window.addEventListener('blur',()=>YG.onPause());
|
||
window.addEventListener('focus',()=>YG.onResume());
|
||
|
||
/* =========================================================
|
||
ЦИКЛ
|
||
========================================================= */
|
||
let last=performance.now();
|
||
function frame(now){
|
||
if(!YG.readySent) YG.ready(); // Y1: LoadingAPI.ready() (треб. 1.19.2) — первый кадр = меню видно
|
||
const dt=Math.min(0.05,(now-last)/1000); last=now;
|
||
if(!YG.paused){ // Y1: под паузой (реклама/скрытая вкладка/платформа) замораживается всё игровое время
|
||
time+=dt;
|
||
setDayTime(dayTime+dt/DAY_LENGTH); // M3: день/ночь идут в реальном времени
|
||
updateWeather(dt); // M8: погода — в реальном времени (как день/ночь)
|
||
updateWeatherHud(); // M8: пилюля погоды в HUD (иконка + остаток секунд)
|
||
updateDecor(dt);
|
||
if(!uiOpen) updateGame(dt);
|
||
// Y6: синхронизация HUD-кнопок — каждый кадр вне паузы (а не только в updateGame):
|
||
// переходы в MENU/под оверлеи (btnResetYes/openShop/btnReset) не гонят updateGame,
|
||
// и без этого #btnPause оставался бы «залипшим» видимым за оверлеем
|
||
syncDom();
|
||
}
|
||
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'); YG.gameplayStop(); } // Y1: геймплейная разметка
|
||
function closeShop(){ uiOpen=false; $('shop').classList.add('hidden'); sfx('click'); YG.gameplayStart(); } // Y1: геймплейная разметка
|
||
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');
|
||
$('tabSkins').classList.toggle('active',t==='skins');
|
||
$('tabLocations').classList.toggle('active',t==='locations');
|
||
}
|
||
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 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>'+t('btn_selected')+'</button>'
|
||
:'<button class="buybtn owned" data-act="use" data-type="'+type+'" data-i="'+i+'">'+t('btn_select')+'</button>';
|
||
if(lv<4) btns+='<button class="buybtn upgbt" data-act="upgrade" data-type="'+type+'" data-i="'+i+'">⬆ '+upgNextText(type,lv-1)+' · '+upgCost(type,i,lv)+' 🪙</button>';
|
||
}else if(!locked){
|
||
btns='<button class="buybtn" data-act="buy" data-type="'+type+'" data-i="'+i+'">'+item.cost+' 🪙</button>';
|
||
}
|
||
let desc=trDesc(item); // Y5: i18n
|
||
if(locked) desc=tt('locked_desc',{name:trName(list[i-1])});
|
||
const lvTag=owned?' <span class="lvtag">'+lvPips(lv)+' '+lv+' '+t('lvl')+'</span>':'';
|
||
return '<div class="item'+(locked?' locked':'')+'"><div><div class="itemname">'+(item.icon||'')+' '+trName(item)+lvTag+'</div>'+
|
||
'<div class="itemdesc">'+desc+'</div></div>'+
|
||
(btns?'<div class="rowbtns">'+btns+'</div>':'<div class="rowbtns"><button class="buybtn" disabled>🔒</button></div>')+'</div>';
|
||
}
|
||
/* Этап 4: строка скина (стиль как у снастей, но проще): название, цена, одна кнопка
|
||
(«Купить N 🪙» / «Выбрать» / «Выбрано ✓») */
|
||
function skinRow(list,i,owned,equipped,group){
|
||
const s=list[i];
|
||
const price=s.cost>0?s.cost+' 🪙':t('free');
|
||
let btn;
|
||
if(equipped){
|
||
btn='<button class="buybtn current" disabled>'+t('btn_selected_chk')+'</button>';
|
||
}else if(owned){
|
||
btn='<button class="buybtn owned" data-act="select" data-type="'+group+'" data-i="'+i+'">'+t('btn_select')+'</button>';
|
||
}else{
|
||
btn='<button class="buybtn" data-act="buyskin" data-type="'+group+'" data-i="'+i+'">'+t('btn_buy')+' '+price+'</button>';
|
||
}
|
||
return '<div class="item"><div><div class="itemname">'+trName(s)+'</div>'+
|
||
'<div class="itemdesc">'+t('price_label')+price+'</div></div>'+
|
||
'<div class="rowbtns">'+btn+'</div></div>';
|
||
}
|
||
/* Этап 5: строка локации (по образцу skinRow): название, подпись, цена, одна кнопка
|
||
(«Купить N 🪙» / «Выбрать» / «Выбрана ✓») */
|
||
function locationRow(loc,i,owned,selected){
|
||
const price=loc.cost>0?loc.cost+' 🪙':t('free');
|
||
let btn;
|
||
if(selected){
|
||
btn='<button class="buybtn current" disabled>'+t('btn_selected_f')+'</button>';
|
||
}else if(owned){
|
||
btn='<button class="buybtn owned" data-act="selectlocation" data-i="'+i+'">'+t('btn_select')+'</button>';
|
||
}else{
|
||
btn='<button class="buybtn" data-act="buylocation" data-i="'+i+'">'+t('btn_buy')+' '+price+'</button>';
|
||
}
|
||
return '<div class="item"><div><div class="itemname">'+trName(loc)+'</div>'+
|
||
'<div class="itemdesc">'+trDesc(loc)+' · '+price+'</div></div>'+
|
||
'<div class="rowbtns">'+btn+'</div></div>';
|
||
}
|
||
function renderShop(){
|
||
const body=$('shopBody');
|
||
if(shopTab==='tackle'){
|
||
let h='<div class="section-title">'+t('sec_rods')+'</div>';
|
||
RODS.forEach((r,i)=>{ h+=itemRow(r,i,'rod',save.rods.indexOf(i)>=0,save.rod===i); });
|
||
h+='<div class="section-title">'+t('sec_lures')+'</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">'+tt('stats',{casts:save.casts,earned:save.earned})+'</div>';
|
||
const vc=v=>v>0?String(v):'—'; // M6: не пойманный вариант — «—»
|
||
for(const s of SPECIES){
|
||
const c=save.caught[s.id]||{n:0,best:0,sn:0,sv:0,rb:0};
|
||
if(c.n>0){
|
||
const sn=c.sn||0, sv=c.sv||0, rb=c.rb||0, nm=c.n-sn-sv-rb;
|
||
// M6: 4 мини-счётчика вариантов (обычная n−sv−sn−rb / серебряная / золотая / радужная);
|
||
// Y5: i18n — подписи вариантов из VARIANTS через trName
|
||
h+='<div class="jrow"><span class="je">'+s.emoji+'</span><span class="jn"><b>'+trName(s)+'</b>'+
|
||
'<span class="jv jv-n" title="'+trName(VARIANTS[0])+'">'+vc(nm)+'</span>'+
|
||
'<span class="jv jv-sv" title="'+trName(VARIANTS[1])+'">'+vc(sv)+'</span>'+
|
||
'<span class="jv jv-sn" title="'+trName(VARIANTS[2])+'">'+vc(sn)+'</span>'+
|
||
'<span class="jv jv-rb" title="'+trName(VARIANTS[3])+'">'+vc(rb)+'</span><br>'+
|
||
'<span style="font-size:11px;color:'+RARITY_COLORS[s.rarity]+'">'+trRarity(s.rarity)+' · '+s.price+' '+t('per_kg')+'</span></span>'+
|
||
'<span class="jc">×'+c.n+'<br>'+tt('max_w',{w:c.best.toFixed(1)})+'</span></div>';
|
||
}else{
|
||
h+='<div class="jrow unknown"><span class="je">❓</span><span class="jn">???</span>'+
|
||
'<span class="jc">'+t('not_caught')+'</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">'+t('sec_streak')+'</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">'+tt('streak_line',{d:(st.days||0),n:nextStreakReward(st.days||1)})+'</div>';
|
||
h+='<div class="section-title">'+t('sec_quests')+'</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">'+t('quests_done_today')+'</div>';
|
||
}
|
||
body.innerHTML=h;
|
||
}else if(shopTab==='ach'){
|
||
// 🏅 Достижения: выданные — яркие, остальные приглушённые
|
||
let h='<div class="section-title">'+t('sec_ach')+'</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>'+trName(a)+'</b><br><span class="adesc">'+trDesc(a)+' · '+bonusText(a.bonus)+'</span></span>'+
|
||
(got?'<span class="jc">✓</span>':'')+'</div>';
|
||
}
|
||
body.innerHTML=h;
|
||
}else if(shopTab==='skins'){
|
||
// Этап 4: скины — лодка и поплавок (чистая косметика, coin-sink)
|
||
let h='<div class="section-title">'+t('sec_boat')+'</div>';
|
||
BOAT_SKINS.forEach((s,i)=>{ h+=skinRow(BOAT_SKINS,i,save.boatSkins.indexOf(i)>=0,save.boatSkin===i,'boat'); });
|
||
h+='<div class="section-title">'+t('sec_bobber')+'</div>';
|
||
BOBBER_SKINS.forEach((s,i)=>{ h+=skinRow(BOBBER_SKINS,i,save.bobberSkins.indexOf(i)>=0,save.bobberSkin===i,'bobber'); });
|
||
body.innerHTML=h;
|
||
}else if(shopTab==='locations'){
|
||
// Этап 5: локации — Река (родная вода) и Озёра (100000 🪙, новые рыбы)
|
||
let h='<div class="section-title">'+t('sec_locations')+'</div>';
|
||
LOCATIONS.forEach((loc,i)=>{ h+=locationRow(loc,i,save.locations.indexOf(i)>=0,save.location===i); });
|
||
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;
|
||
if(b.dataset.act==='buylocation'||b.dataset.act==='selectlocation'){
|
||
// Этап 5: локации — покупка (монеты списать, push, АВТО-ПЕРЕХОД) и выбор
|
||
const loc=LOCATIONS[i];
|
||
if(!loc) return;
|
||
if(loc.id!==i) return; // защита: индекс массива не обязан совпадать с id при будущей вставке записей
|
||
if(b.dataset.act==='buylocation'){
|
||
if(save.locations.indexOf(i)>=0){ sfx('no'); return; } // уже куплено — защита
|
||
if(save.coins<loc.cost){ sfx('no'); toast(t('not_enough')); return; }
|
||
save.coins-=loc.cost; save.locations.push(i);
|
||
save.location=i; // авто-переход на купленную локацию
|
||
persist(); updateHUD(); sfx('coin'); renderShop();
|
||
toast(loc.id===1?t('welcome_lakes'):tt('bought',{name:trName(loc)}));
|
||
}else{
|
||
if(save.locations.indexOf(i)<0){ sfx('no'); return; } // не куплено — выбрать нельзя (паттерн защиты buy)
|
||
save.location=i;
|
||
persist(); updateHUD(); sfx('click'); renderShop();
|
||
toast(tt('location_set',{name:trName(loc)}));
|
||
}
|
||
return;
|
||
}
|
||
if(type==='boat'||type==='bobber'){
|
||
// Этап 4: скины — покупка (монеты списать, авто-экипировка) и выбор
|
||
const slist=type==='boat'?BOAT_SKINS:BOBBER_SKINS;
|
||
const owned=type==='boat'?save.boatSkins:save.bobberSkins;
|
||
const item=slist[i];
|
||
if(b.dataset.act==='buyskin'){
|
||
if(!item||owned.indexOf(i)>=0){ sfx('no'); return; } // уже куплено — защита
|
||
if(save.coins<item.cost){ sfx('no'); toast(t('not_enough')); return; }
|
||
save.coins-=item.cost; owned.push(i);
|
||
if(type==='boat') save.boatSkin=i; else save.bobberSkin=i; // авто-экипировка
|
||
persist(); updateHUD(); sfx('coin'); renderShop();
|
||
toast(tt('bought',{name:trName(item)}));
|
||
}else{
|
||
if(!item||owned.indexOf(i)<0){ sfx('no'); return; } // не куплено — выбрать нельзя (паттерн защиты buy)
|
||
if(type==='boat') save.boatSkin=i; else save.bobberSkin=i;
|
||
persist(); updateHUD(); sfx('click'); renderShop();
|
||
}
|
||
return;
|
||
}
|
||
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 item=list[i];
|
||
if(b.dataset.act==='buy'){
|
||
if(i>0&&lvMap[list[i-1].id]<4){ sfx('no'); toast(tt('gate_toast',{name:trName(list[i-1])})); return; } // M5: гейтинг
|
||
if(save.coins<item.cost){ sfx('no'); toast(t('not_enough')); 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(tt('bought',{name:trName(item)}));
|
||
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=upgCost(type,i,lv);
|
||
if(save.coins<cost){ sfx('no'); toast(t('not_enough')); return; }
|
||
save.coins-=cost; lvMap[i]=lv+1; // M5: апгрейд — уровень++
|
||
persist(); updateHUD(); sfx('coin'); renderShop();
|
||
toast(tt('upgraded',{name:trName(item),lv: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,variant,boss){
|
||
$('cardRarity').textContent=trRarity(sp.rarity);
|
||
$('cardRarity').style.color=RARITY_COLORS[sp.rarity];
|
||
$('cardEmoji').textContent=sp.emoji;
|
||
$('cardName').textContent=trName(sp)+(boss?t('card_boss_suffix'):''); // M4: имя босса; Y5: i18n
|
||
$('cardSub').textContent=tt('card_sub',{w:w.toFixed(1),price:sp.price});
|
||
$('cardVal').textContent='+'+val+' 🪙';
|
||
$('cardRecord').classList.toggle('hidden',!rec);
|
||
// M6: бейдж варианта + оформление карточки (silver/gold/rainbow; normal — без)
|
||
$('card').classList.toggle('silver-card',variant.css==='silver');
|
||
$('card').classList.toggle('gold-card',variant.css==='gold');
|
||
$('card').classList.toggle('rainbow-card',variant.css==='rainbow');
|
||
$('cardVariant').classList.toggle('hidden',variant.id==='normal');
|
||
$('cardVariant').textContent=trName(variant).toUpperCase();
|
||
// M4: босс: бейдж 🏆 + золотое оформление карточки
|
||
$('cardBoss').classList.toggle('hidden',!boss);
|
||
$('card').classList.toggle('boss-card',!!boss);
|
||
// Y2: rewarded «монеты ×2» — бонус = полная цена улова (rvVal), сброс флага
|
||
rvVal=val; rvDone=false;
|
||
$('btnRv').classList.remove('hidden');
|
||
$('btnRv').disabled=false;
|
||
$('btnRv').textContent=t('rv_btn');
|
||
$('card').classList.remove('hidden');
|
||
}
|
||
|
||
/* =========================================================
|
||
КНОПКИ
|
||
========================================================= */
|
||
$('btnPlay').addEventListener('click',()=>{
|
||
ac(); sfx('click');
|
||
$('menu').classList.add('hidden');
|
||
uiOpen=false; state='IDLE';
|
||
if(!YG.paused) YG.gameplayStart(); // Y1: геймплейная разметка (треб. 1.19.3; под паузой резюме поднимет сам)
|
||
});
|
||
$('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'); });
|
||
$('tabSkins').addEventListener('click',()=>{ setTab('skins'); sfx('click'); }); // Этап 4: вкладка скинов
|
||
$('tabLocations').addEventListener('click',()=>{ setTab('locations'); sfx('click'); }); // Этап 5: вкладка локаций
|
||
$('shopBody').addEventListener('click',shopClick);
|
||
$('btnMute').addEventListener('click',()=>{
|
||
save.muted=!save.muted; persist(); updateHUD(); sfx('click');
|
||
});
|
||
$('btnRecall').addEventListener('click',()=>{ recall(); });
|
||
// Y6: кнопка паузы (треб. Яндекс Игр 6.3) — оверлей «Продолжить/В меню».
|
||
// Общие гарды: паузу можно открыть только в игре (не MENU, без оверлеев и рекламы);
|
||
// снять её может только closePause()/«В меню» (YG.onResume под гардом pauseOpen).
|
||
function openPause(){
|
||
if(state==='MENU'||uiOpen||adShowing||pauseOpen) return;
|
||
sfx('click');
|
||
pauseOpen=true; uiOpen=true;
|
||
YG.onPause();
|
||
$('pause').classList.remove('hidden');
|
||
$('btnPause').classList.add('hidden'); // под паузой updateGame/syncDom не гонятся — прячем руками
|
||
}
|
||
function closePause(){
|
||
if(!pauseOpen) return;
|
||
sfx('click');
|
||
pauseOpen=false; uiOpen=(state==='MENU');
|
||
$('pause').classList.add('hidden');
|
||
YG.onResume();
|
||
}
|
||
$('btnPause').addEventListener('click',openPause);
|
||
$('btnPauseResume').addEventListener('click',closePause);
|
||
$('btnPauseMenu').addEventListener('click',()=>{
|
||
sfx('click');
|
||
pauseOpen=false; uiOpen=true;
|
||
state='MENU';
|
||
$('pause').classList.add('hidden');
|
||
$('menu').classList.remove('hidden');
|
||
YG.onResume(); // state==='MENU' — gameplayStart не вызовет
|
||
});
|
||
$('btnReset').addEventListener('click',()=>{
|
||
sfx('click');
|
||
uiOpen=true; // пауза, пока идёт подтверждение
|
||
$('resetDlg').classList.remove('hidden');
|
||
YG.gameplayStop(); // Y1: геймплейная разметка (треб. 1.19.3)
|
||
});
|
||
$('btnResetNo').addEventListener('click',()=>{
|
||
sfx('click');
|
||
$('resetDlg').classList.add('hidden');
|
||
uiOpen=(state==='MENU');
|
||
if(!uiOpen) YG.gameplayStart(); // Y1: геймплей возобновился (не в меню и без оверлея)
|
||
});
|
||
$('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; forceVariant=null; forceWeather=null; // T2b: тест-форсы тоже сбрасываем (симметрия); M8: погода
|
||
weather=null; weatherT=0; weatherGap=weatherGapNext(); // M8: чистый старт — новый погодный таймер
|
||
$('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(t('progress_reset'));
|
||
});
|
||
// Y2: interstitial на закрытии карточки улова (треб. 4.4 — логическая пауза).
|
||
// Гейт: первый показ не раньше AD_FIRST_MS сессии, затем — через AD_COOLDOWN_MS.
|
||
// persist() — ПЕРЕД паузой (треб. 4.2: процесс сохранён до перехода на рекламу);
|
||
// YG.onPause() — звук и весь игровой процесс (треб. 4.7). В стабе showFullscreen
|
||
// вернёт false — endAd() сразу, игра не зависает в adShowing=true.
|
||
function tryInterstitial(){
|
||
if(adShowing) return;
|
||
const now=Date.now();
|
||
if(now-sessionStartTs < AD_FIRST_MS) return;
|
||
if(now-lastAdTs < AD_COOLDOWN_MS) return;
|
||
lastAdTs=now; // отмечаем ПОПЫТКУ, а не показ: при троттлинге платформы (wasShown=false) кулдаун сгорает — это осознанно, ограничивает частоту вызовов (retry-storm)
|
||
adShowing=true;
|
||
persist(); // 4.2: процесс сохранён до перехода на рекламу
|
||
YG.onPause(); // 4.7: звук + весь игровой процесс на паузе
|
||
const ok=YG.showFullscreen({
|
||
onOpen(){},
|
||
onClose(){ endAd(); }, // onClose(wasShown) приходит и при троттлинге
|
||
onError(){ endAd(); }
|
||
});
|
||
if(!ok) endAd(); // стаб: не показана — сразу резюм
|
||
}
|
||
function endAd(){ clearTimeout(YG.adWatchdog); YG.adWatchdog=null; adShowing=false; YG.onResume(); } // Y2: реклама закрыта/ошибка — снимаем паузу и останавливаем watchdog
|
||
$('btnCardOk').addEventListener('click',()=>{
|
||
sfx('click');
|
||
$('card').classList.add('hidden');
|
||
state='IDLE';
|
||
tryInterstitial(); // Y2: после возврата в IDLE; гейт может пропустить (внутри return)
|
||
});
|
||
// Y2: rewarded «монеты ×2» (треб. 4.5.1 — что показываем и что даём, написано на
|
||
// кнопке и в title; 4.5.2 — чистый бонус, игру не блокирует: карточка остаётся,
|
||
// закрыть можно в любой момент). В стабе onRewarded срабатывает синхронно — весь
|
||
// флоу в одном клике; в реальном SDK onRewarded придёт после просмотра, onClose —
|
||
// позже (endAd именно через onClose, не раньше).
|
||
$('btnRv').addEventListener('click',()=>{
|
||
if(rvDone||adShowing) return;
|
||
sfx('click');
|
||
adShowing=true;
|
||
YG.onPause(); // 4.7
|
||
const ok=YG.showRewarded({
|
||
onOpen(){},
|
||
onRewarded(){
|
||
rvDone=true;
|
||
save.coins+=rvVal; // бонус = полная цена улова ещё раз
|
||
persist(); updateHUD();
|
||
$('cardVal').textContent='+'+(rvVal*2)+' 🪙';
|
||
$('btnRv').disabled=true;
|
||
$('btnRv').textContent=t('rv_done');
|
||
sfx('coin'); vib(30);
|
||
},
|
||
onClose(){ endAd(); },
|
||
onError(){ endAd(); }
|
||
});
|
||
if(!ok) endAd();
|
||
});
|
||
|
||
/* =========================================================
|
||
СТАРТ
|
||
========================================================= */
|
||
loadSave();
|
||
resize();
|
||
initFish();
|
||
updateHUD();
|
||
renderMenu(); // Y5: i18n — меню из T (device-aware; стаб-ветка уже вызывала через resolveLang, это страховка)
|
||
renderStaticUI(); // Y5: i18n — статичные подписи (лавка/вкладки/сброс/карточка)
|
||
requestAnimationFrame(frame);
|
||
</script>
|
||
</body>
|
||
</html>
|