Files
fishing-game/index.html
T
andrey a90123be3b Игра «Рыбалка»: HTML5-канвас, автотесты, скриншоты
- один самодостаточный index.html (мобильное управление, магазин,
  дневник, сохранение в localStorage)
- автотест геймплея в headless Chromium (tests/test.js, 21 проверка)
- скриншоты состояния игры (screenshots/)
2026-08-26 18:15:31 +00:00

891 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#0d3b66">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Рыбалка</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none}
html,body{width:100%;height:100%;overflow:hidden;background:#0d3b66;font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}
#game{position:fixed;inset:0;touch-action:none;display:block}
button{font-family:inherit;cursor:pointer}
.hidden{display:none!important}
/* ---------- HUD ---------- */
#hud{position:fixed;top:0;left:0;right:0;display:flex;justify-content:space-between;align-items:center;
padding:calc(8px + env(safe-area-inset-top)) 10px 8px;z-index:10;pointer-events:none}
#coins{background:rgba(6,25,45,.55);padding:8px 14px;border-radius:999px;color:#ffe9a8;font-weight:700;
font-size:17px;border:1px solid rgba(255,255,255,.15)}
.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,.15);background:rgba(6,25,45,.55);color:#fff}
.hudbtns button:active{background:rgba(30,60,95,.8)}
#btnRecall{position:fixed;right:12px;bottom:calc(16px + env(safe-area-inset-bottom));width:54px;height:54px;
border-radius:50%;font-size:24px;background:rgba(6,25,45,.65);color:#fff;
border:1px solid rgba(255,255,255,.2);z-index:10}
#btnRecall:active{background:rgba(30,60,95,.85)}
#toast{position:fixed;left:50%;bottom:calc(92px + env(safe-area-inset-bottom));transform:translateX(-50%);
background:rgba(6,25,45,.9);color:#fff;padding:10px 18px;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,.15)}
/* ---------- Overlays ---------- */
.overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
background:rgba(6,20,38,.6);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);z-index:20;padding:16px}
.panel{position:relative;background:linear-gradient(#14406b,#0d2f4e);border:2px solid #2e6da3;border-radius:20px;
padding:22px;width:min(92vw,370px);max-height:86vh;overflow-y:auto;color:#eaf4ff;text-align:center;
box-shadow:0 10px 40px rgba(0,0,0,.5)}
.title{font-size:34px;font-weight:800;letter-spacing:1px;margin-bottom:10px}
.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(#ffd76e,#f5a623);border:none;border-radius:14px;box-shadow:0 4px 0 #b97708}
.big:active{transform:translateY(2px);box-shadow:0 2px 0 #b97708}
.panelhead{font-size:22px;font-weight:800;margin-bottom:4px}
#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}
.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}
.tabs button.active{background:#f5a623;color:#04263d}
.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,.08);border-radius:12px;padding:10px 12px;margin-bottom:8px;text-align:left}
.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}
.buybtn:active{transform:scale(.96)}
.buybtn.owned{background:rgba(255,255,255,.14)}
.buybtn.current{background:#2ecc71;color:#04263d}
.buybtn:disabled{opacity:.45}
.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-radius:10px;margin-bottom:6px;font-size:14px;text-align:left}
.jrow .je{font-size:22px;width:30px;text-align:center}
.jrow .jn{flex:1}
.jrow .jc{color:#9fc3e2;font-weight:600;font-size:13px}
.jrow.unknown .je,.jrow.unknown .jn{color:#5d7f9e}
#card .bigemoji{font-size:74px;margin:4px 0 2px;display:block;animation:bob 1.2s ease-in-out infinite}
@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-9px)}}
#card .rarity{font-weight:800;font-size:13px;letter-spacing:1.5px;text-transform:uppercase}
#card .cname{font-size:24px;font-weight:800;margin:2px 0 6px}
#card .cval{font-size:20px;font-weight:800;color:#ffe9a8;margin-bottom:6px}
#card{animation:pop .3s ease}
@keyframes pop{from{transform:scale(.75);opacity:0}to{transform:scale(1);opacity:1}}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="hud">
<div id="coins">🪙 0</div>
<div class="hudbtns">
<button id="btnShop">🛒</button>
<button id="btnMute">🔊</button>
</div>
</div>
<button id="btnRecall" class="hidden">↩</button>
<div id="toast"></div>
<!-- Меню -->
<div id="menu" class="overlay">
<div class="panel">
<div class="title">🎣 РЫБАЛКА</div>
<p>👆 <b>Держи палец</b> — заряжай бросок,<br>
<b>отпусти</b> — кидай удочку.<br>
Дальше бросок = реже и жирнее рыба!</p>
<p style="margin-top:8px">Видишь <b style="color:#ff6b5e">!</b> — быстро жми, чтобы зацепить.<br>
При вываживании: тяни, когда рыба отдыхает,<br>
отпусти, когда она <b>тянет</b> — иначе лопнет!</p>
<button id="btnPlay" class="big">Играть</button>
</div>
</div>
<!-- Магазин -->
<div id="shop" class="overlay hidden">
<div class="panel">
<button id="btnCloseShop">×</button>
<div class="panelhead">🛒 Лавка рыбака</div>
<div class="tabs">
<button id="tabTackle" class="active">Снасти</button>
<button id="tabJournal">Дневник</button>
</div>
<div id="shopBody"></div>
</div>
</div>
<!-- Карточка улова -->
<div id="card" class="overlay hidden">
<div class="panel">
<div class="rarity" id="cardRarity">Обычная</div>
<span class="bigemoji" id="cardEmoji">🐟</span>
<div class="cname" id="cardName">Карась</div>
<div class="cval" id="cardVal">+10 🪙</div>
<button id="btnCardOk" class="big">Отлично!</button>
</div>
</div>
<script>
'use strict';
/* =========================================================
ДАННЫЕ
========================================================= */
const SPECIES=[
{id:'carass', name:'Карась', emoji:'🐟', rarity:0, weight:100, minDist:0.00, strength:0.25, value:10, hue:45},
{id:'perch', name:'Окунь', emoji:'🐠', rarity:0, weight:55, minDist:0.05, strength:0.30, value:15, hue:120},
{id:'bream', name:'Лещ', emoji:'🐠', rarity:1, weight:26, minDist:0.30, strength:0.40, value:25, hue:210},
{id:'carp', name:'Карп', emoji:'🐡', rarity:1, weight:22, minDist:0.35, strength:0.45, value:30, hue:30},
{id:'pike', name:'Щука', emoji:'🦈', rarity:2, weight:12, minDist:0.50, strength:0.60, value:60, hue:100},
{id:'catfish',name:'Сом', emoji:'🐡', rarity:2, weight:7, minDist:0.65, strength:0.75, value:90, hue:260},
{id:'sturge', name:'Осётр', emoji:'🐋', rarity:3, weight:3.5, minDist:0.80, strength:0.85, value:200, hue:200},
{id:'gold', name:'Золотая рыбка', emoji:'🎏', rarity:4, weight:1.2, minDist:0.90, strength:0.90, value:500, hue:45},
];
const RARITY_NAMES=['Обычная','Необычная','Редкая','Эпическая','Легендарная'];
const RARITY_COLORS=['#9fc3e2','#2ecc71','#4da3ff','#c56cf0','#ffd75e'];
const RODS=[
{id:0, name:'Дедова удочка', cost:0, line:100, reel:1.0, desc:'Нагрузка 100 · Подсечка ×1.0'},
{id:1, name:'Стеклопластик', cost:250, line:140, reel:1.3, desc:'Нагрузка 140 · Подсечка ×1.3'},
{id:2, name:'Карбоновая', cost:700, line:190, reel:1.7, desc:'Нагрузка 190 · Подсечка ×1.7'},
{id:3, name:'Титан', cost:1600, line:260, reel:2.3, desc:'Нагрузка 260 · Подсечка ×2.3'},
];
const LURES=[
{id:0, name:'Простой крючок', cost:0, cast:1.00, bite:1.0, rare:0.0, desc:'Надёжно, но рыба не в восторге'},
{id:1, name:'Червь', cost:150, cast:1.00, bite:1.3, rare:0.2, desc:'Клёв на 30% чаще'},
{id:2, name:'Воблер', cost:450, cast:1.15, bite:1.2, rare:0.8, desc:'Дальше бросок, приманивает редких'},
{id:3, name:'Золотая блёстка',cost:1200, cast:1.20, bite:1.4, rare:2.0, desc:'Лучшее, что есть. Легенды плывут к ней'},
];
/* =========================================================
СОХРАНЕНИЕ
========================================================= */
const SAVE_KEY='fishing-save-v1';
let save={coins:0,rod:0,lure:0,rods:[0],lures:[0],caught:{},muted:false,casts:0,earned:0};
function loadSave(){ try{ const s=localStorage.getItem(SAVE_KEY); if(s) Object.assign(save,JSON.parse(s)); }catch(e){} }
function persist(){ try{ localStorage.setItem(SAVE_KEY,JSON.stringify(save)); }catch(e){} }
/* =========================================================
ХОЛСТ И РАЗМЕРЫ
========================================================= */
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
let W=0,H=0,DPR=1,waterY=0,boatX=0;
let tip={x:0,y:0};
function resize(){
DPR=Math.min(window.devicePixelRatio||1,2);
W=window.innerWidth; H=window.innerHeight;
canvas.width=W*DPR; canvas.height=H*DPR;
canvas.style.width=W+'px'; canvas.style.height=H+'px';
ctx.setTransform(DPR,0,0,DPR,0,0);
waterY=Math.round(H*0.26);
boatX=Math.round(W*0.20);
initClouds(); initSparkles();
}
window.addEventListener('resize',resize);
/* =========================================================
ЗВУК
========================================================= */
let AC=null;
function ac(){
if(!AC){ try{ AC=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){ return null; } }
if(AC&&AC.state==='suspended'){ AC.resume(); }
return AC;
}
function beep(t0,f,dur,type,vol){
const c=ac(); if(!c)return;
const o=c.createOscillator(),g=c.createGain();
o.type=type||'sine'; o.frequency.setValueAtTime(f,t0);
g.gain.setValueAtTime(vol,t0); g.gain.exponentialRampToValueAtTime(0.001,t0+dur);
o.connect(g); g.connect(c.destination); o.start(t0); o.stop(t0+dur+0.02);
}
function noiseBurst(t0,dur,vol,freq){
const c=ac(); if(!c)return;
const n=Math.floor(c.sampleRate*dur);
const buf=c.createBuffer(1,n,c.sampleRate),d=buf.getChannelData(0);
for(let i=0;i<n;i++) d[i]=(Math.random()*2-1)*(1-i/n);
const src=c.createBufferSource(); src.buffer=buf;
const f=c.createBiquadFilter(); f.type='lowpass'; f.frequency.value=freq;
const g=c.createGain(); g.gain.value=vol;
src.connect(f); f.connect(g); g.connect(c.destination); src.start(t0);
}
function sfx(name){
if(save.muted) return;
const c=ac(); if(!c) return;
const t=c.currentTime;
switch(name){
case 'click': beep(t,500,0.06,'triangle',0.15); break;
case 'cast': noiseBurst(t,0.22,0.15,2500); break;
case 'splash': noiseBurst(t,0.3,0.4,700); break;
case 'bite': beep(t,900,0.07,'square',0.2); beep(t+0.1,1200,0.09,'square',0.2); break;
case 'hook': beep(t,600,0.05,'triangle',0.25); beep(t+0.06,900,0.08,'triangle',0.25); break;
case 'tick': beep(t,260,0.03,'triangle',0.07); break;
case 'pull': beep(t,140,0.18,'sawtooth',0.12); break;
case 'snap': noiseBurst(t,0.25,0.5,3000); beep(t,180,0.2,'sawtooth',0.2); break;
case 'escape': beep(t,500,0.12,'sine',0.2); beep(t+0.12,350,0.16,'sine',0.18); break;
case 'coin': beep(t,1200,0.08,'sine',0.2); beep(t+0.09,1600,0.12,'sine',0.2); break;
case 'catch': [523,659,784,1047].forEach((f,i)=>beep(t+i*0.11,f,0.14,'triangle',0.22)); break;
case 'no': beep(t,220,0.12,'square',0.15); break;
}
}
function vib(p){ try{ if(navigator.vibrate) navigator.vibrate(p); }catch(e){} }
/* =========================================================
СОСТОЯНИЕ ИГРЫ
========================================================= */
let state='MENU'; // MENU IDLE CHARGE CAST WAIT APPROACH BITE REEL SNAP CARD
let uiOpen=true;
let time=0;
let charge=0, power=0;
let castT=0, castFrom={x:0,y:0}, castTo={x:0,y:0};
let bobber={x:0,y:0};
let biteTimer=0, biteT=0, biteFish=null, rippleTimer=0;
let reel=null;
let snapT=0, shake=0;
let ambient=[]; // фоновые рыбы
let clouds=[], sparkles=[];
let particles=[], ripples=[];
const rod=()=>RODS[save.rod];
const lure=()=>LURES[save.lure];
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)});
}
}
/* =========================================================
УТИЛИТЫ
========================================================= */
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();
let total=0;
const ws=SPECIES.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<SPECIES.length;i++){ r-=ws[i]; if(r<=0) return SPECIES[i]; }
return SPECIES[0];
}
function biteWindow(sp){ return Math.max(0.35, 0.8 - sp.rarity*0.08 + save.rod*0.04); }
function distTo(sp){ return 55 + sp.strength*75; }
function startBite(){
const dist=Math.min(1,(castTo.x-(boatX+30))/Math.max(1,W-boatX-70));
const sp=pickSpecies(dist);
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';
const d0=distTo(sp);
reel={d:d0, d0:d0, tension:0, holding:false, phase:'calm', phaseT:rand(0.8,1.5), sp:sp, tick:0};
}
function doCast(){
const p=Math.max(0.12,power);
const dist=Math.min(0.97,(0.10+0.80*p)*lure().cast);
castFrom={x:tip.x,y:tip.y};
castTo={x:boatX+30+dist*Math.max(1,W-boatX-70), y:waterY-2};
castT=0; state='CAST'; sfx('cast');
save.casts++; 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, st=sp.strength;
reel.phaseT-=dt;
if(reel.phaseT<=0){
if(reel.phase==='calm'){
reel.phase='pull'; reel.phaseT=0.35+st*rand(0.3,0.9);
sfx('pull'); vib(40);
}else{
reel.phase='calm'; reel.phaseT=rand(0.7,1.6); vib(15);
}
}
const pulling=reel.phase==='pull';
if(reel.holding){
reel.d-=30*r.reel*dt;
reel.tension+=(pulling?22+st*55:9)*dt;
reel.tick-=dt;
if(reel.tick<=0){ sfx('tick'); reel.tick=0.12; }
if(Math.random()<0.3) splash(castTo.x,waterY,1,40);
}else{
reel.tension-=38*dt;
if(pulling) reel.d+=7*st*dt;
}
reel.tension=Math.max(0,reel.tension);
if(reel.tension>=r.line){
state='SNAP'; snapT=1.0; shake=0.5;
sfx('snap'); vib(200); toast('💥 Линия лопнула!');
}else if(reel.d<=0){
catchFish(sp);
}else if(reel.d>=reel.d0*1.4){
state='WAIT'; biteTimer=rand(3,7)/lure().bite;
sfx('escape'); toast('Рыба сорвалась…');
}
}
function catchFish(sp){
const val=Math.round(sp.value*(0.9+Math.random()*0.3));
save.coins+=val; save.earned+=val;
save.caught[sp.id]=(save.caught[sp.id]||0)+1;
persist(); updateHUD();
sfx('catch'); vib([40,60,40]);
splash(castTo.x,waterY,26,140);
for(let i=0;i<10;i++){
particles.push({x:castTo.x,y:waterY,vx:rand(-60,60),vy:rand(-220,-80),g:300,
life:rand(0.5,0.9),max:0.9,size:rand(2,4),color:'255,215,94'});
}
reel=null; state='CARD';
showCard(sp,val);
}
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(2.5,7)/lure().bite;
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) startBite();
break;
}
case 'APPROACH':
updateBiteFishIn(dt);
if(biteFish){
if(biteFish.t>=1){
state='BITE';
biteT=biteWindow(biteFish.sp);
sfx('bite'); vib(60);
addRipple(castTo.x,waterY+3,30);
}
}
break;
case 'BITE':
biteT-=dt;
bobber.y=waterY+5+Math.sin(time*22)*2;
if(Math.random()<0.2) addRipple(castTo.x,waterY+3,26);
if(biteT<=0){
if(biteFish) biteFish.mode='out';
state='WAIT'; biteTimer=rand(3,8)/lure().bite;
sfx('escape'); toast('Ушла… жми быстрее!');
}
break;
case 'REEL':
updateReel(dt);
if(state==='REEL'&&reel){
bobber.x=castTo.x+(reel.holding?Math.sin(time*30)*2:0);
bobber.y=waterY-2+Math.sin(time*9)*2;
if(Math.random()<0.25) addRipple(castTo.x,waterY+3,24);
}
break;
case 'SNAP':
snapT-=dt;
if(snapT<=0) state='IDLE';
break;
}
syncDom();
}
function updateBiteFishIn(dt){
biteFish.t+=dt/0.45;
const t=Math.min(1,biteFish.t);
const e=t*t*(3-2*t);
biteFish.x=biteFish.sx+(castTo.x-biteFish.sx)*e;
biteFish.y=biteFish.sy+(waterY+14-biteFish.sy)*e;
biteFish.dir=castTo.x>biteFish.sx?1:-1;
}
function updateBiteFishOut(dt){
biteFish.t+=dt/0.6;
const dir=biteFish.x<W/2?-1:1;
biteFish.x+=dir*380*dt;
biteFish.y+=40*dt;
if(biteFish.t>=1||biteFish.x<-50||biteFish.x>W+50) biteFish=null;
}
function updateDecor(dt){
for(const c of clouds){ c.x+=c.v*dt; if(c.x>W+80) c.x=-80; }
for(let i=particles.length-1;i>=0;i--){
const p=particles[i];
p.life-=dt; p.vy+=p.g*dt; p.x+=p.vx*dt; p.y+=p.vy*dt;
if(p.life<=0||p.y>H) particles.splice(i,1);
}
for(let i=ripples.length-1;i>=0;i--){
const r=ripples[i];
r.r+=45*dt; r.a-=0.9*dt;
if(r.a<=0||r.r>r.max) ripples.splice(i,1);
}
if(shake>0) shake-=dt;
}
function syncDom(){
const showRecall=(state==='WAIT'||state==='APPROACH'||state==='BITE');
$('btnRecall').classList.toggle('hidden',!showRecall);
}
/* =========================================================
ОТРИСОВКА
========================================================= */
function rr(x,y,w,h,r){
ctx.beginPath();
ctx.moveTo(x+r,y);
ctx.arcTo(x+w,y,x+w,y+h,r);
ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r);
ctx.arcTo(x,y,x+w,y,r);
ctx.closePath();
}
function drawSky(){
const g=ctx.createLinearGradient(0,0,0,waterY);
g.addColorStop(0,'#7ec8e3'); g.addColorStop(1,'#cfeef7');
ctx.fillStyle=g; ctx.fillRect(0,0,W,waterY);
// солнце
ctx.fillStyle='rgba(255,230,120,0.35)';
ctx.beginPath(); ctx.arc(W-52,48,34,0,7); ctx.fill();
ctx.fillStyle='#ffe27a';
ctx.beginPath(); ctx.arc(W-52,48,20,0,7); ctx.fill();
// облака
ctx.fillStyle='rgba(255,255,255,0.9)';
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 g=ctx.createLinearGradient(0,waterY,0,H);
g.addColorStop(0,'#2f9bd8'); g.addColorStop(1,'#0a2f52');
ctx.fillStyle=g; ctx.fillRect(0,waterY,W,H-waterY);
// блики
for(const s of sparkles){
const a=Math.max(0,Math.sin(time*1.5+s.ph))*0.25;
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)'; 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();
}
function drawFishShape(x,y,size,dir,hue,alpha){
ctx.save(); ctx.translate(x,y); ctx.scale(dir,1);
ctx.fillStyle='hsla('+hue+',55%,38%,'+alpha+')';
ctx.beginPath(); ctx.ellipse(0,0,size,size*0.45,0,0,7); ctx.fill();
ctx.beginPath();
ctx.moveTo(-size*0.8,0); ctx.lineTo(-size*1.45,-size*0.42); ctx.lineTo(-size*1.45,size*0.42);
ctx.closePath(); 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));
}
}
function drawBoat(){
const bx=boatX, by=waterY+Math.sin(time*1.5)*1.5;
// лодка
ctx.fillStyle='#b0622d';
ctx.beginPath();
ctx.moveTo(bx-32,by); ctx.lineTo(bx+32,by); ctx.lineTo(bx+21,by+15); ctx.lineTo(bx-21,by+15);
ctx.closePath(); ctx.fill();
ctx.fillStyle='#8a4519'; ctx.fillRect(bx-32,by,64,4);
// рыбак
ctx.fillStyle='#34495e';
ctx.fillRect(bx-9,by-18,12,12);
ctx.fillStyle='#e8b88a';
ctx.beginPath(); ctx.arc(bx-3,by-24,6.5,0,7); ctx.fill();
ctx.fillStyle='#c0392b';
ctx.beginPath(); ctx.arc(bx-3,by-27,6.5,Math.PI,0); ctx.fill();
// удочка
const swing=state==='CHARGE'?-power*0.35:0;
ctx.strokeStyle='#5d4023'; ctx.lineWidth=3; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(bx+2,by-14);
const tx=bx+34+swing*20, ty=by-38-swing*14;
ctx.lineTo(tx,ty); ctx.stroke();
tip={x:tx,y:ty};
// флажок
ctx.strokeStyle='#5d4023'; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(bx-24,by); ctx.lineTo(bx-24,by-22); ctx.stroke();
ctx.fillStyle='#f5a623';
ctx.beginPath(); ctx.moveTo(bx-24,by-22); ctx.lineTo(bx-12,by-18); ctx.lineTo(bx-24,by-14); ctx.closePath(); ctx.fill();
}
function drawLineTo(x,y,sag){
ctx.strokeStyle='rgba(230,240,255,0.8)'; ctx.lineWidth=1.2;
ctx.beginPath(); ctx.moveTo(tip.x,tip.y);
ctx.quadraticCurveTo((tip.x+x)/2,(tip.y+y)/2+sag,x,y);
ctx.stroke();
}
function drawBobber(){
const x=bobber.x,y=bobber.y;
ctx.fillStyle='#fff';
ctx.beginPath(); ctx.arc(x,y,5.5,0,7); ctx.fill();
ctx.fillStyle='#e74c3c';
ctx.beginPath(); ctx.arc(x,y,5.5,Math.PI,0); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,0.9)'; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.arc(x,y,5.5,0,7); ctx.stroke();
}
function drawLineAndBobber(){
if(state==='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.font='34px system-ui'; ctx.textAlign='center';
ctx.fillText('💥',castTo.x,waterY-20);
ctx.globalAlpha=1;
return;
}
if(state==='CAST'){ drawLineTo(bobber.x,bobber.y,14); drawBobber(); return; }
const sag=state==='REEL'?(reel.phase==='pull'?4:12):18+Math.sin(time*2)*3;
drawLineTo(bobber.x,bobber.y,sag);
drawBobber();
if(state==='BITE'){
const yy=bobber.y-24+Math.sin(time*12)*2;
ctx.font='800 28px system-ui'; ctx.textAlign='center';
ctx.lineWidth=4; ctx.strokeStyle='#fff';
ctx.strokeText('!',bobber.x,yy);
ctx.fillStyle='#ff3b30';
ctx.fillText('!',bobber.x,yy);
}
}
function drawHookedFish(){
if(state!=='REEL'||!reel) return;
const sp=reel.sp;
const x=castTo.x+Math.sin(time*7)*4;
const y=waterY+26+Math.sin(time*5)*3;
const dir=Math.sin(time*2.5)>0?1:-1;
drawFishShape(x,y,10+sp.rarity*2.5,dir,sp.hue,0.75);
}
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';
// --- нагрузка (вертикально справа) ---
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/r.line);
const col=ratio<0.5?'#2ecc71':ratio<0.8?'#f1c40f':'#e74c3c';
if(ratio>0.01){
ctx.fillStyle=col;
const fh=bh*ratio;
rr(bx+2,by+bh-2-fh,bw-4,Math.max(2,fh-2),5); ctx.fill();
}
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();
ctx.font='16px system-ui'; ctx.textAlign='center';
ctx.fillText(pulling?'⚡':'💪',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){
ctx.fillStyle='#3fa7e8';
rr(dx+2,dy+2,Math.max(2,(dw-4)*rem),dh-4,5); ctx.fill();
}
// рыбка-маркер на конце полоски
const fx=dx+2+(dw-4)*rem;
ctx.font='20px system-ui'; ctx.textAlign='center';
ctx.fillText('🐟',fx,dy-6+Math.sin(time*10)*1.5);
ctx.font='18px system-ui';
ctx.fillText('⛵',dx+4,dy+dh+18);
// статус
ctx.font='700 15px system-ui'; ctx.textAlign='center';
if(pulling){
ctx.fillStyle='#ff6b5e';
ctx.fillText('⚡ РЫБА ТЯНЕТ — ОТПУСТИ! ⚡',W/2,dy-30);
}else{
ctx.fillStyle='#7ef0a8';
ctx.fillText('Держи палец — втягивай!',W/2,dy-30);
}
}
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){ rr(x+2,y+2,Math.max(2,(w-4)*power),h-4,6); ctx.fill(); }
ctx.font='700 14px system-ui'; ctx.textAlign='center';
ctx.fillStyle='#fff';
ctx.fillText('Отпусти — бросок!',W/2,y-10);
}
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.fillText('👆 Держи палец — заряжай бросок',W/2,H-70);
ctx.globalAlpha=1;
}
function draw(){
ctx.clearRect(0,0,W,H);
ctx.save();
if(shake>0){
ctx.translate(rand(-1,1)*shake*8,rand(-1,1)*shake*8);
}
drawSky();
drawWater();
drawAmbientFish();
if(biteFish&&biteFish.mode!=='out') drawFishShape(biteFish.x,biteFish.y,12+biteFish.sp.rarity*2.5,biteFish.dir,biteFish.sp.hue,0.8);
else if(biteFish&&biteFish.mode==='out') drawFishShape(biteFish.x,biteFish.y,12+biteFish.sp.rarity*2.5,biteFish.dir,biteFish.sp.hue,0.5);
drawBoat();
drawLineAndBobber();
drawHookedFish();
drawRipples();
drawParticles();
drawMeters();
drawPowerBar();
drawHint();
ctx.restore();
}
/* =========================================================
ЦИКЛ
========================================================= */
let last=performance.now();
function frame(now){
const dt=Math.min(0.05,(now-last)/1000); last=now;
time+=dt;
updateDecor(dt);
if(!uiOpen) updateGame(dt);
draw();
requestAnimationFrame(frame);
}
/* =========================================================
ВВОД
========================================================= */
function onDown(){
ac();
if(uiOpen) return;
if(state==='IDLE'){ state='CHARGE'; charge=0; power=0; }
else if(state==='APPROACH'||state==='BITE'){ hook(); }
else if(state==='REEL'&&reel){ reel.holding=true; }
}
function onUp(){
if(state==='CHARGE') doCast();
else if(state==='REEL'&&reel) reel.holding=false;
}
canvas.addEventListener('pointerdown',e=>{ e.preventDefault(); onDown(); });
window.addEventListener('pointerup',onUp);
window.addEventListener('pointercancel',onUp);
document.addEventListener('contextmenu',e=>e.preventDefault());
/* =========================================================
UI: МАГАЗИН / ДНЕВНИК / КАРТОЧКА
========================================================= */
let shopTab='tackle';
function openShop(){ uiOpen=true; $('shop').classList.remove('hidden'); renderShop(); sfx('click'); }
function closeShop(){ uiOpen=false; $('shop').classList.add('hidden'); sfx('click'); }
function setTab(t){ shopTab=t; renderShop();
$('tabTackle').classList.toggle('active',t==='tackle');
$('tabJournal').classList.toggle('active',t==='journal');
}
function itemRow(item,i,type,owned,cur){
let btn;
if(cur) btn='<button class="buybtn current" disabled>✓ Выбрано</button>';
else if(owned) btn='<button class="buybtn owned" data-act="use" data-type="'+type+'" data-i="'+i+'">Выбрать</button>';
else btn='<button class="buybtn" data-act="buy" data-type="'+type+'" data-i="'+i+'">'+item.cost+' 🪙</button>';
return '<div class="item"><div><div class="itemname">'+item.name+'</div>'+
'<div class="itemdesc">'+item.desc+'</div></div>'+btn+'</div>';
}
function renderShop(){
const body=$('shopBody');
if(shopTab==='tackle'){
let h='<div class="section-title">Удилища</div>';
RODS.forEach((r,i)=>{ h+=itemRow(r,i,'rod',save.rods.indexOf(i)>=0,save.rod===i); });
h+='<div class="section-title">Приманки</div>';
LURES.forEach((l,i)=>{ h+=itemRow(l,i,'lure',save.lures.indexOf(i)>=0,save.lure===i); });
body.innerHTML=h;
}else{
let h='<div class="statsline">Бросков: '+save.casts+' · Заработано: '+save.earned+' 🪙</div>';
for(const s of SPECIES){
const c=save.caught[s.id]||0;
if(c>0){
h+='<div class="jrow"><span class="je">'+s.emoji+'</span><span class="jn"><b>'+s.name+'</b><br>'+
'<span style="font-size:11px;color:'+RARITY_COLORS[s.rarity]+'">'+RARITY_NAMES[s.rarity]+' · '+s.value+' 🪙</span></span>'+
'<span class="jc">×'+c+'</span></div>';
}else{
h+='<div class="jrow unknown"><span class="je">❓</span><span class="jn">???</span>'+
'<span class="jc">не пойман</span></div>';
}
}
body.innerHTML=h;
}
}
function shopClick(e){
const b=e.target.closest('[data-act]');
if(!b) return;
const type=b.dataset.type, i=+b.dataset.i;
const list=type==='rod'?RODS:LURES;
const owned=type==='rod'?save.rods:save.lures;
const item=list[i];
if(b.dataset.act==='buy'){
if(save.coins<item.cost){ sfx('no'); toast('Не хватает монет 🪙'); return; }
save.coins-=item.cost; owned.push(i);
if(type==='rod') save.rod=i; else save.lure=i;
persist(); updateHUD(); sfx('coin'); renderShop();
toast('Куплено: '+item.name+'!');
}else{
if(type==='rod') save.rod=i; else save.lure=i;
persist(); updateHUD(); sfx('click'); renderShop();
}
}
function showCard(sp,val){
$('cardRarity').textContent=RARITY_NAMES[sp.rarity];
$('cardRarity').style.color=RARITY_COLORS[sp.rarity];
$('cardEmoji').textContent=sp.emoji;
$('cardName').textContent=sp.name;
$('cardVal').textContent='+'+val+' 🪙';
$('card').classList.remove('hidden');
}
/* =========================================================
КНОПКИ
========================================================= */
$('btnPlay').addEventListener('click',()=>{
ac(); sfx('click');
$('menu').classList.add('hidden');
uiOpen=false; state='IDLE';
});
$('btnShop').addEventListener('click',openShop);
$('btnCloseShop').addEventListener('click',closeShop);
$('tabTackle').addEventListener('click',()=>{ setTab('tackle'); sfx('click'); });
$('tabJournal').addEventListener('click',()=>{ setTab('journal'); sfx('click'); });
$('shopBody').addEventListener('click',shopClick);
$('btnMute').addEventListener('click',()=>{
save.muted=!save.muted; persist(); updateHUD(); sfx('click');
});
$('btnRecall').addEventListener('click',()=>{ recall(); });
$('btnCardOk').addEventListener('click',()=>{
sfx('click');
$('card').classList.add('hidden');
state='IDLE';
});
/* =========================================================
СТАРТ
========================================================= */
loadSave();
resize();
initFish();
updateHUD();
requestAnimationFrame(frame);
</script>
</body>
</html>