Files
Toolbox/public/tools/greedy_snake/js/main.js
T
yangxiangyuan 6dc79a2c12 feat: 新增多项功能并添加新工具
- 私人剪贴板新增限时直通URL生成功能
- doc_cloud_keeper新增标题复制按钮
- 新增贪蛇出动游戏工具
- 新增OSS中转直链工具
- 添加批量抽帧测试脚本
2026-08-11 10:22:31 +08:00

338 lines
13 KiB
JavaScript
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.
/* 贪蛇出动 - 主控:状态机 / 游戏循环 / 击杀与死亡 / 复活 / 服务端数据同步 */
(function () {
'use strict';
window.GS = window.GS || {};
const U = GS.U;
const API = '/api/greedy_snake';
class Game {
constructor() {
this.ui = new GS.UI();
this.render = new GS.Render(document.getElementById('gs-canvas'));
this.input = new GS.Input();
this.state = 'idle'; // idle / roulette / playing / dead / result
this.stats = { gems: GS.CFG.initialGems, totalCoins: 0, best: 0, todayBest: null };
this.player = null;
this.bots = [];
this.ais = new Map();
this.world = null;
this.selectedItem = -1;
this.reelKeys = [];
this.freeReviveUsed = false;
this.treasureDone = false;
this._last = 0;
this._boardTimer = 0;
this._respawnQueue = [];
this._killerName = '';
this._reviveTimer = null;
this._bindUI();
}
async init() {
// 拉取服务端配置与存档
const cfg = await this.ui.trackFetch('加载游戏配置', API + '/config');
if (cfg && cfg.ok && cfg.data) {
Object.assign(GS.CFG, {
worldSize: cfg.data.world_size || GS.CFG.worldSize,
botCount: cfg.data.bot_count || GS.CFG.botCount,
coinCount: cfg.data.coin_count || GS.CFG.coinCount,
baseSpeed: cfg.data.base_speed || GS.CFG.baseSpeed,
boostSpeed: cfg.data.boost_speed || GS.CFG.boostSpeed,
treasureTarget: cfg.data.treasure_target || GS.CFG.treasureTarget,
rerollCost: cfg.data.reroll_cost || GS.CFG.rerollCost,
initialGems: cfg.data.initial_gems || GS.CFG.initialGems,
reviveCountdown: cfg.data.revive_countdown || GS.CFG.reviveCountdown,
playerName: cfg.data.player_name || GS.CFG.playerName
});
this.ui.setUiMode(cfg.data.ui_mode || 'debug');
}
const st = await this.ui.trackFetch('加载存档', API + '/stats');
if (st && st.ok && st.data) this.stats = Object.assign(this.stats, st.data);
document.getElementById('gs-progress-target').textContent = GS.CFG.treasureTarget;
this._openRoulette();
requestAnimationFrame(t => this._loop(t));
}
/* ---------------- 转盘阶段 ---------------- */
_openRoulette() {
this.state = 'roulette';
this.input.setVisible(false);
this.ui.showHud(false);
this.ui.hideResult();
this.ui.hideRevive();
this.selectedItem = -1;
this._rollReels();
this.ui.showRoulette(this.stats.gems);
}
_rollReels() {
const pool = GS.ITEM_KEYS.slice();
this.reelKeys = [U.pick(pool), U.pick(pool), U.pick(pool)];
this.ui.spinReels(this.reelKeys);
setTimeout(() => this.ui.setReels(this.reelKeys, this.selectedItem), 900);
}
_bindUI() {
// 转盘:选择道具 / 换一换 / 直接开始
document.querySelectorAll('.gs-reel').forEach(reel => {
reel.addEventListener('click', () => {
const idx = Number(reel.dataset.idx);
this.selectedItem = this.selectedItem === idx ? -1 : idx;
this.ui.setReels(this.reelKeys, this.selectedItem);
});
});
document.getElementById('gs-reroll').addEventListener('click', async () => {
if (this.stats.gems < GS.CFG.rerollCost) { this.ui.toast('钻石不足', 'error'); return; }
const btn = document.getElementById('gs-reroll');
btn.disabled = true;
this.stats.gems -= GS.CFG.rerollCost;
this.ui.updateGems(this.stats.gems);
this._saveStats({ gems: this.stats.gems });
this.selectedItem = -1;
this._rollReels();
});
document.getElementById('gs-roulette-skip').addEventListener('click', () => this._startGame(null));
document.getElementById('gs-reels').addEventListener('dblclick', () => {
if (this.selectedItem >= 0) this._startGame(this.reelKeys[this.selectedItem]);
});
// 复活
document.getElementById('gs-revive-btn').addEventListener('click', () => this._revive());
document.getElementById('gs-revive-close').addEventListener('click', () => this._toResult());
// 结算
document.getElementById('gs-result-again').addEventListener('click', () => this._openRoulette());
document.getElementById('gs-result-home').addEventListener('click', () => this._openRoulette());
// 弹窗
document.getElementById('gs-dialog-close').addEventListener('click', () => this.ui.hideDialog());
document.getElementById('gs-dialog-copy').addEventListener('click', () => {
const txt = document.getElementById('gs-dialog-body').textContent;
if (navigator.clipboard) navigator.clipboard.writeText(txt).then(() => this.ui.toast('已复制', 'success'));
});
}
/* ---------------- 开局 ---------------- */
_startGame(itemKey) {
this.ui.hideRoulette();
this.world = new GS.World();
this.bots = [];
this.ais.clear();
this._respawnQueue = [];
this.freeReviveUsed = false;
this.treasureDone = false;
// 玩家
const px = U.rand(800, GS.CFG.worldSize - 800);
const py = U.rand(800, GS.CFG.worldSize - 800);
this.player = new GS.Snake({ id: 'p', name: GS.CFG.playerName, isPlayer: true, color: GS.PLAYER_COLOR, x: px, y: py });
this._applyItem(this.player, itemKey);
// AI
for (let i = 0; i < GS.CFG.botCount; i++) this._spawnBot();
this.state = 'playing';
this.input.setVisible(true);
this.ui.showHud(true);
if (itemKey) this.ui.toast('已装备:' + GS.ITEMS[itemKey].name, 'success');
}
_applyItem(s, key) {
if (!key) return;
const now = performance.now();
if (key === 'helmet') s.shield = true;
if (key === 'rocket') s.invincibleUntil = now + 4000;
if (key === 'magnet') s.magnetUntil = now + 20000;
if (key === 'expcard') s.grow(200);
}
_spawnBot() {
const b = new GS.Snake({
id: 'b' + Math.random().toString(36).slice(2, 7),
name: U.pick(GS.BOT_NAMES),
color: U.pick(GS.SNAKE_COLORS),
skin: Math.random() < 0.5 ? 1 : 0,
x: U.rand(300, GS.CFG.worldSize - 300),
y: U.rand(300, GS.CFG.worldSize - 300),
score: U.randInt(10, 400)
});
this.bots.push(b);
this.ais.set(b.id, new GS.AI(b));
}
/* ---------------- 主循环 ---------------- */
_loop(t) {
const dt = Math.min(0.05, (t - this._last) / 1000 || 0.016);
this._last = t;
if (this.state === 'playing' || this.state === 'dead') this._step(dt, t);
if (this.world) this.render.draw(this.world, this._allSnakes(), this.player, t);
requestAnimationFrame(tt => this._loop(tt));
}
_allSnakes() { return this.player ? [this.player, ...this.bots] : this.bots; }
_step(dt, now) {
const p = this.player;
// 玩家输入
if (this.state === 'playing' && !p.dead) {
p.targetAngle = this.input.angle;
const wantBoost = this.input.boost;
p.boosting = wantBoost && p.score > 15;
if (p.boosting) {
p.score = Math.max(10, p.score - 6 * dt); // 加速消耗长度
if (Math.random() < dt * 2) this.world.addCoin(
p.head.x - Math.cos(p.angle) * p.radius * 2,
p.head.y - Math.sin(p.angle) * p.radius * 2, 0);
}
}
// AI 决策
for (const b of this.bots) this.ais.get(b.id).update(dt, this.world, this._allSnakes());
// 移动
for (const s of this._allSnakes()) s.update(dt, now);
// 金币/磁铁
this.world.update(dt, this._allSnakes(), now);
// 相机
if (!p.dead) this.world.updateCam(p.head, this.render.w, this.render.h, dt);
// 碰撞与死亡
const snakes = this._allSnakes();
for (const s of snakes) {
if (s.dead) continue;
const hit = this.world.collide(s, snakes);
if (!hit) continue;
if (s.shield) { s.shield = false; s.invincibleUntil = now + 1500; continue; }
if (s.invincible(now)) continue;
this._onDeath(s, hit, now);
}
// 复活队列
for (let i = this._respawnQueue.length - 1; i >= 0; i--) {
const r = this._respawnQueue[i];
if (now >= r.at) { this._respawnQueue.splice(i, 1); this._spawnBot(); }
}
// 宝藏屋进度
if (!this.treasureDone && p && !p.dead && p.score >= GS.CFG.treasureTarget) {
this.treasureDone = true;
this.world.treasureBurst(p.head.x, p.head.y);
p.lucky += 5;
this.ui.toast('长度达标!宝藏屋开启,幸运星 +5', 'success');
}
// HUD 刷新
this._boardTimer -= dt;
if (this._boardTimer <= 0) {
this._boardTimer = 0.5;
this._refreshHud();
}
}
_onDeath(s, hit, now) {
const segs = s.die();
this.world.dropFromSegments(segs);
if (hit.type === 'body' && hit.snake) hit.snake.kills += 1;
if (s.isPlayer) {
this._killerName = hit.type === 'wall' ? '' : (hit.snake ? hit.snake.name : '对手');
this.state = 'dead';
this.input.setVisible(false);
this.input.boost = false;
this._startReviveCountdown();
} else {
// 玩家击杀判定:撞到玩家身体
if (hit.type === 'body' && hit.snake === this.player) this.ui.toast('击杀 ' + s.name + '!', 'success');
this.bots = this.bots.filter(b => b !== s);
this.ais.delete(s.id);
this._respawnQueue.push({ at: now + 3000 });
}
}
/* ---------------- 复活 / 结算 ---------------- */
_startReviveCountdown() {
const total = GS.CFG.reviveCountdown;
let left = total;
this.ui.showRevive(this._killerName, this.freeReviveUsed);
this.ui.updateReviveCount(left, 1);
clearInterval(this._reviveTimer);
this._reviveTimer = setInterval(() => {
left -= 0.1;
if (left <= 0) { clearInterval(this._reviveTimer); this._toResult(); return; }
this.ui.updateReviveCount(Math.ceil(left), left / total);
}, 100);
}
_revive() {
if (this.freeReviveUsed) return;
this.freeReviveUsed = true;
clearInterval(this._reviveTimer);
const p = this.player;
p.dead = false;
// 安全点重生:世界中心附近
const x = U.clamp(p.head.x + U.rand(-600, 600), 400, GS.CFG.worldSize - 400);
const y = U.clamp(p.head.y + U.rand(-600, 600), 400, GS.CFG.worldSize - 400);
p.path = [{ x, y }];
p.pathLen = 0;
p.shield = true;
p.invincibleUntil = performance.now() + 3000;
this.ui.hideRevive();
this.input.setVisible(true);
this.state = 'playing';
this.ui.toast('复活成功,护盾保护中', 'success');
}
_toResult() {
clearInterval(this._reviveTimer);
this.ui.hideRevive();
this.state = 'result';
const p = this.player;
const score = Math.floor(p.score);
if (score > this.stats.best) this.stats.best = score;
this.stats.totalCoins += p.coins;
this.stats.gems += p.kills * 5 + p.lucky;
this.ui.showResult({ score, coins: p.coins, kills: p.kills, lucky: p.lucky, best: this.stats.best });
this._saveStats({ score, coins: p.coins, kills: p.kills, lucky: p.lucky, gems: this.stats.gems, totalCoins: this.stats.totalCoins });
}
_refreshHud() {
const p = this.player;
if (!p) return;
this.ui.updateStats(this.stats.totalCoins + p.coins, p.lucky, p.kills);
this.ui.updateProgress(p.score, GS.CFG.treasureTarget, p.score >= 1000 && !this.treasureDone);
// 排行榜
const rows = this._allSnakes().filter(s => !s.dead)
.map(s => ({ name: s.name, score: Math.floor(s.score), me: s.isPlayer }))
.sort((a, b) => b.score - a.score);
const top = rows.slice(0, 9);
const meRow = rows.find(r => r.me);
if (meRow && !top.includes(meRow)) top.push(meRow);
this.ui.updateBoard(top, meRow, this.stats.todayBest && this.stats.todayBest.score >= 0 ? this.stats.todayBest : { name: '-', score: this.stats.best });
}
/* ---------------- 服务端同步 ---------------- */
async _saveStats(extra) {
const body = Object.assign({}, extra);
const res = await this.ui.trackFetch('保存战绩', API + '/stats', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (res && res.ok && res.data) {
this.stats.best = res.data.best != null ? res.data.best : this.stats.best;
this.stats.gems = res.data.gems != null ? res.data.gems : this.stats.gems;
this.stats.totalCoins = res.data.totalCoins != null ? res.data.totalCoins : this.stats.totalCoins;
this.stats.todayBest = res.data.todayBest || this.stats.todayBest;
}
}
}
window.addEventListener('DOMContentLoaded', () => {
const g = new Game();
GS.game = g;
g.init();
});
})();