91148fe064
1. 新增物理弹球游戏全套代码,包含物理引擎、规则、渲染、游戏逻辑、存档、音效、UI 2. 新增物理弹球相关调试与单元测试脚本 3. 优化私有剪贴上传功能:增加200MB上传限制与错误提示 4. 修复复古弹球桌的光滑面防粘附与休眠逻辑
318 lines
11 KiB
JavaScript
318 lines
11 KiB
JavaScript
/* ============================================================
|
|
* pb_ui.js · 物理弹球 Web 复刻版 · 界面 / 输入 / 主循环
|
|
* 屏幕:主菜单(直接拖拽开局) / 指南 / 暂停 / 结束 / 记录 / 设置
|
|
* 存档走 PBStorage,音效走 PBAudio。
|
|
* ============================================================ */
|
|
var PBUi = (function () {
|
|
'use strict';
|
|
var R = PBRules, F = R.FIELD;
|
|
var g = null;
|
|
var canvas, ctx;
|
|
var lastTs = 0, globalT = 0;
|
|
|
|
function $(id) { return document.getElementById(id); }
|
|
function show(id) { $(id).classList.remove('hidden'); }
|
|
function hide(id) { $(id).classList.add('hidden'); }
|
|
|
|
function fmtSecs(s) {
|
|
var m = Math.floor(s / 60), ss = Math.floor(s % 60);
|
|
return (m < 10 ? '0' + m : m) + ':' + (ss < 10 ? '0' + ss : ss);
|
|
}
|
|
|
|
// ---- 主菜单演示背景 ----
|
|
function toIdleDemo() {
|
|
PBGame.startGame(g);
|
|
g.state = 'idle';
|
|
$('pb-best').textContent = '最佳: ' + PBStorage.getBest().toLocaleString();
|
|
$('pb-ball-count').textContent = PBStorage.getBalls();
|
|
if (PBStorage.canClaimDaily()) show('pb-daily'); else hide('pb-daily');
|
|
show('pb-menu'); hide('pb-hud'); hide('pb-over');
|
|
}
|
|
|
|
// ---- 开局(消耗 1 球;不足时救济 +1,保证可玩) ----
|
|
function tryStart() {
|
|
if (!PBStorage.spendBall()) {
|
|
PBStorage.addBalls(1);
|
|
PBStorage.spendBall();
|
|
}
|
|
PBGame.startGame(g);
|
|
lastScore = 0;
|
|
hide('pb-menu'); show('pb-hud');
|
|
}
|
|
|
|
function toLogical(e) {
|
|
var rect = canvas.getBoundingClientRect();
|
|
return {
|
|
x: (e.clientX - rect.left) / rect.width * F.W,
|
|
y: (e.clientY - rect.top) / rect.height * F.H
|
|
};
|
|
}
|
|
|
|
function setAim(p) {
|
|
var fx = F.arcCx, fy = F.checkY - 6;
|
|
var dx = p.x - fx, dy = p.y - fy;
|
|
if (dy < 40) dy = 40;
|
|
var ang = Math.atan2(dx, dy);
|
|
g.aimAngle = Math.max(-1.25, Math.min(1.25, ang));
|
|
}
|
|
|
|
var downPos = null, pendingStart = false;
|
|
function dist(a, b) { var dx = a.x - b.x, dy = a.y - b.y; return Math.sqrt(dx * dx + dy * dy); }
|
|
|
|
function onDown(e) {
|
|
PBAudio.unlock();
|
|
if (g.paused || g.state === 'over') return;
|
|
var p = toLogical(e);
|
|
if (g.state === 'idle') { pendingStart = true; downPos = p; return; } // 主菜单:拖拽超阈值才开局,防误触
|
|
if (g.state === 'aim' && g.queue.length) {
|
|
g.aiming = true;
|
|
downPos = p;
|
|
setAim(p);
|
|
}
|
|
}
|
|
function onMove(e) {
|
|
var p = toLogical(e);
|
|
if (pendingStart && downPos) {
|
|
if (dist(p, downPos) > 14) { // 确认是拖拽才开局,并直接进入瞄准
|
|
pendingStart = false;
|
|
tryStart();
|
|
g.aiming = true;
|
|
setAim(p);
|
|
}
|
|
return;
|
|
}
|
|
if (g.aiming) setAim(p);
|
|
}
|
|
function onUp(e) {
|
|
if (pendingStart) { pendingStart = false; downPos = null; return; } // 主菜单轻点 = 无操作
|
|
if (g.aiming) {
|
|
g.aiming = false;
|
|
var p = e && e.clientX !== undefined ? toLogical(e) : null;
|
|
if (p && downPos && dist(p, downPos) < 10) { downPos = null; return; } // 轻点 = 取消瞄准,不发射
|
|
downPos = null;
|
|
PBGame.fire(g);
|
|
}
|
|
}
|
|
|
|
// ---- 结束结算 ----
|
|
function onOver() {
|
|
PBStorage.addRecord(g.score, Math.floor(g.secs));
|
|
var best = PBStorage.getBest();
|
|
var isNew = g.score > best;
|
|
if (isNew) PBStorage.setBest(g.score);
|
|
$('pb-over-score').textContent = g.score.toLocaleString();
|
|
$('pb-over-time').textContent = '用时 ' + fmtSecs(g.secs);
|
|
$('pb-over-new').style.display = isNew ? '' : 'none';
|
|
$('pb-over-best').textContent = '最佳: ' + PBStorage.getBest().toLocaleString();
|
|
show('pb-over');
|
|
hide('pb-hud');
|
|
}
|
|
|
|
// ---- 记录页 ----
|
|
function renderRecords() {
|
|
var list = PBStorage.getRecords();
|
|
var box = $('pb-records-list');
|
|
box.innerHTML = '';
|
|
if (!list.length) {
|
|
box.innerHTML = '<div class="pb-rec-empty">暂无记录,去打一局吧</div>';
|
|
return;
|
|
}
|
|
for (var i = 0; i < list.length; i++) {
|
|
var r = list[i];
|
|
var row = document.createElement('div');
|
|
row.className = 'pb-rec-row ' + (i % 2 ? 'alt' : '');
|
|
var color = R.RECORD_COLORS[i % R.RECORD_COLORS.length];
|
|
row.innerHTML =
|
|
'<span class="pb-rec-rank" style="color:' + color + '">' + (i + 1) + '.</span>' +
|
|
'<span class="pb-rec-date" style="color:' + color + '">' + r.date + '</span>' +
|
|
'<span class="pb-rec-right"><b style="color:' + color + '">' + r.score.toLocaleString() + '</b>' +
|
|
'<i style="color:' + color + '">' + fmtSecs(r.secs) + '</i></span>';
|
|
box.appendChild(row);
|
|
}
|
|
}
|
|
|
|
// ---- 指南演示小图 ----
|
|
function drawGuideDemo() {
|
|
var c = $('pb-guide-demo');
|
|
if (!c) return;
|
|
var x = c.getContext('2d');
|
|
x.clearRect(0, 0, c.width, c.height);
|
|
x.strokeStyle = '#8a8a8a'; x.lineWidth = 2;
|
|
x.beginPath();
|
|
x.moveTo(20, 10); x.lineTo(20, 150);
|
|
x.moveTo(200, 10); x.lineTo(200, 150);
|
|
x.moveTo(20, 10); x.lineTo(95, 42);
|
|
x.moveTo(200, 10); x.lineTo(125, 42);
|
|
x.stroke();
|
|
x.setLineDash([4, 4]);
|
|
x.beginPath(); x.moveTo(20, 48); x.lineTo(200, 48); x.stroke();
|
|
x.setLineDash([]);
|
|
// 待发球
|
|
x.fillStyle = '#a97a45';
|
|
[[105, 34], [117, 30], [93, 30], [111, 20]].forEach(function (p) {
|
|
x.beginPath(); x.arc(p[0], p[1], 5, 0, 7); x.fill();
|
|
});
|
|
// 轨迹点
|
|
x.fillStyle = '#c89458';
|
|
for (var i = 0; i < 9; i++) {
|
|
x.beginPath(); x.arc(112 + i * 8, 56 + i * 11, 3, 0, 7); x.fill();
|
|
}
|
|
// 彩色砖块
|
|
var blocks = [
|
|
['#f7c81a', 55, 95, '3'], ['#8bd420', 95, 95, '10'], ['#37c837', 130, 95, '8'], ['#f7c81a', 185, 95, '1'],
|
|
['#37c837', 90, 122, '13'], ['#2fd4c8', 125, 122, '24'], ['#f7c81a', 172, 122, '6'],
|
|
['#2f7bff', 55, 148, '80'], ['#29c8e2', 115, 148, '29'], ['#a02fe0', 185, 148, '100']
|
|
];
|
|
blocks.forEach(function (b) {
|
|
x.fillStyle = b[0];
|
|
x.fillRect(b[1] - 11, b[2] - 9, 22, 18);
|
|
x.fillStyle = b[3].length > 2 ? '#fff' : '#20180a';
|
|
x.font = 'bold 10px sans-serif'; x.textAlign = 'center'; x.textBaseline = 'middle';
|
|
x.fillText(b[3], b[1], b[2] + 1);
|
|
});
|
|
// 道具
|
|
x.strokeStyle = '#eee'; x.setLineDash([3, 3]);
|
|
x.beginPath(); x.arc(152, 122, 8, 0, 7); x.stroke();
|
|
x.setLineDash([]);
|
|
}
|
|
|
|
// ---- ∞ 三色 logo ----
|
|
function drawLogo() {
|
|
var c = $('pb-logo');
|
|
if (!c) return;
|
|
var x = c.getContext('2d');
|
|
x.clearRect(0, 0, c.width, c.height);
|
|
var colors = ['#f7c81a', '#2f7bff', '#ff2f7b'];
|
|
for (var k = 0; k < colors.length; k++) {
|
|
x.strokeStyle = colors[k];
|
|
x.lineWidth = 5;
|
|
x.beginPath();
|
|
var off = (k - 1) * 4;
|
|
for (var i = 0; i <= 200; i++) {
|
|
var t = i / 200 * Math.PI * 2;
|
|
var s2 = Math.sin(t) * Math.sin(t);
|
|
var px = 110 + 78 * Math.cos(t) / (1 + s2);
|
|
var py = 60 + (46 + off) * Math.sin(t) * Math.cos(t) / (1 + s2);
|
|
if (i === 0) x.moveTo(px, py); else x.lineTo(px, py);
|
|
}
|
|
x.stroke();
|
|
}
|
|
}
|
|
|
|
function toast(msg) {
|
|
var t = $('pb-toast');
|
|
t.textContent = msg;
|
|
t.classList.add('show');
|
|
setTimeout(function () { t.classList.remove('show'); }, 1800);
|
|
}
|
|
|
|
// ---- 分数弹跳反馈 ----
|
|
var lastScore = 0;
|
|
function popScore() {
|
|
var el = $('pb-score');
|
|
el.classList.remove('pop');
|
|
void el.offsetWidth;
|
|
el.classList.add('pop');
|
|
}
|
|
|
|
// ---- 主循环 ----
|
|
function loop(ts) {
|
|
var dt = Math.min(0.033, (ts - lastTs) / 1000 || 0.016);
|
|
lastTs = ts;
|
|
globalT += dt;
|
|
var prevState = g.state;
|
|
PBGame.update(g, dt);
|
|
if (g.state === 'over' && prevState !== 'over') onOver();
|
|
PBRender.draw(ctx, g, globalT);
|
|
// HUD
|
|
if (g.state !== 'idle') {
|
|
$('pb-time').textContent = '时间 ' + fmtSecs(g.secs);
|
|
$('pb-score').textContent = g.score.toLocaleString();
|
|
$('pb-balls').textContent = '🥎 ' + (g.queue.length + g.balls.length + g.recycle.length);
|
|
$('pb-big').classList.toggle('hidden', !g.bigTurn);
|
|
if (g.score !== lastScore) { lastScore = g.score; popScore(); }
|
|
}
|
|
requestAnimationFrame(loop);
|
|
}
|
|
|
|
function bind() {
|
|
canvas = $('pb-canvas');
|
|
ctx = canvas.getContext('2d');
|
|
canvas.addEventListener('pointerdown', onDown);
|
|
window.addEventListener('pointermove', onMove);
|
|
window.addEventListener('pointerup', onUp);
|
|
|
|
$('pb-pause-btn').addEventListener('click', function () {
|
|
if (g.state === 'idle' || g.state === 'over') return;
|
|
g.paused = true;
|
|
show('pb-pause');
|
|
});
|
|
$('pb-resume').addEventListener('click', function () { g.paused = false; hide('pb-pause'); });
|
|
$('pb-restart').addEventListener('click', function () {
|
|
hide('pb-pause');
|
|
g.paused = false;
|
|
tryStart();
|
|
});
|
|
$('pb-share').addEventListener('click', function () {
|
|
var text = '我在「物理弹球」' + fmtSecs(g.secs) + ' 打了 ' + g.score.toLocaleString() + ' 分,来挑战我!';
|
|
try {
|
|
navigator.clipboard.writeText(text).then(function () { toast('成绩已复制,去分享吧'); }, function () { toast(text); });
|
|
} catch (e) { toast(text); }
|
|
});
|
|
|
|
$('pb-again').addEventListener('click', function () {
|
|
hide('pb-over');
|
|
tryStart();
|
|
});
|
|
$('pb-to-menu').addEventListener('click', function () { toIdleDemo(); });
|
|
|
|
$('pb-help').addEventListener('click', function () { drawGuideDemo(); show('pb-guide'); });
|
|
$('pb-guide-ok').addEventListener('click', function () { hide('pb-guide'); PBStorage.markGuideSeen(); });
|
|
|
|
$('pb-gear').addEventListener('click', function () {
|
|
$('pb-sound-toggle').checked = PBStorage.soundOn();
|
|
$('pb-escape-slider').value = PBStorage.getEscape();
|
|
$('pb-escape-val').textContent = PBStorage.getEscape();
|
|
show('pb-settings');
|
|
});
|
|
$('pb-settings-close').addEventListener('click', function () { hide('pb-settings'); });
|
|
$('pb-sound-toggle').addEventListener('change', function (e) {
|
|
PBStorage.setSound(e.target.checked);
|
|
toast(e.target.checked ? '音效已开启' : '音效已关闭');
|
|
});
|
|
// 逃逸强度滑块:实时存储,局内立即生效
|
|
$('pb-escape-slider').addEventListener('input', function (e) {
|
|
PBStorage.setEscape(e.target.value);
|
|
$('pb-escape-val').textContent = PBStorage.getEscape();
|
|
});
|
|
|
|
$('pb-side-records').addEventListener('click', function () { renderRecords(); show('pb-records'); });
|
|
$('pb-records-close').addEventListener('click', function () { hide('pb-records'); });
|
|
|
|
$('pb-daily').addEventListener('click', function () {
|
|
if (PBStorage.claimDaily()) {
|
|
PBAudio.claim();
|
|
toast('领取成功 +3 球');
|
|
$('pb-ball-count').textContent = PBStorage.getBalls();
|
|
hide('pb-daily');
|
|
}
|
|
});
|
|
$('pb-side-balls').addEventListener('click', function () {
|
|
toast('当前球数 ' + PBStorage.getBalls() + ';每局消耗 1 球,局内 +1 球道具可补充');
|
|
});
|
|
}
|
|
|
|
function init() {
|
|
g = PBGame.create();
|
|
bind();
|
|
drawLogo();
|
|
toIdleDemo();
|
|
if (!PBStorage.guideSeen()) { drawGuideDemo(); show('pb-guide'); }
|
|
requestAnimationFrame(function (ts) { lastTs = ts; requestAnimationFrame(loop); });
|
|
}
|
|
|
|
return { init: init };
|
|
})();
|
|
|
|
window.addEventListener('load', function () { PBUi.init(); });
|