feat: 新增多项功能并添加新工具
- 私人剪贴板新增限时直通URL生成功能 - doc_cloud_keeper新增标题复制按钮 - 新增贪蛇出动游戏工具 - 新增OSS中转直链工具 - 添加批量抽帧测试脚本
This commit is contained in:
@@ -889,6 +889,24 @@
|
||||
background: #bfdbfe;
|
||||
}
|
||||
|
||||
.dck-copy-title-btn {
|
||||
display: inline-block;
|
||||
min-width: 24px;
|
||||
padding: 1px 5px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #dbeafe;
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dck-copy-title-btn:hover {
|
||||
background: #bfdbfe;
|
||||
}
|
||||
|
||||
.dck-preview-body {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
|
||||
@@ -733,6 +733,7 @@ const dck = (() => {
|
||||
${parentFolderName ? `<span class="dck-doc-parent-tag" title="直属父目录:${esc(parentFolderName)}">${esc(parentFolderName)}</span>` : ''}
|
||||
<button type="button" class="dck-preview-btn" data-doc-id="${r.id}" title="快速查看 ${esc(r.filename)}">👁</button>
|
||||
<button type="button" class="dck-version-count-tag" data-doc-id="${r.id}" title="查看 ${esc(r.filename)} 的历史版本">${versionCount}</button>
|
||||
<button type="button" class="dck-copy-title-btn" data-doc-id="${r.id}" title="复制标题">📋</button>
|
||||
</span>
|
||||
`
|
||||
tr.innerHTML = `
|
||||
@@ -797,6 +798,23 @@ const dck = (() => {
|
||||
await openPreview(doc)
|
||||
}
|
||||
})
|
||||
Array.from(tb.querySelectorAll('.dck-copy-title-btn')).forEach(btn => {
|
||||
btn.onclick = async e => {
|
||||
e.stopPropagation()
|
||||
const docId = Number(btn.dataset.docId || 0)
|
||||
if (!docId) return
|
||||
const doc = docs.find(item => Number(item.id) === docId)
|
||||
if (!doc) return
|
||||
try {
|
||||
await copyText(doc.filename || '')
|
||||
btn.textContent = '✅'
|
||||
setTimeout(() => { btn.textContent = '📋' }, 1200)
|
||||
} catch (err) {
|
||||
btn.textContent = '⚠️'
|
||||
setTimeout(() => { btn.textContent = '📋' }, 1200)
|
||||
}
|
||||
}
|
||||
})
|
||||
renderBanner()
|
||||
updateActionButtonsState()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"_comment": "贪蛇出动的鉴权配置",
|
||||
"enable_auth_comment": "true:启用 tool 外层鉴权;false:完全放开访问",
|
||||
"enable_auth": true,
|
||||
"max_age_days_comment": "鉴权 cookie 最大有效天数(1~365)",
|
||||
"max_age_days": 30,
|
||||
"cookieName_comment": "鉴权 Cookie 名称,默认格式 <tool>_gate",
|
||||
"cookieName": "greedy_snake_gate",
|
||||
"requireGuid_comment": "true:强制 GUID 参数才能访问;false:允许仅凭 token 或已有会话访问",
|
||||
"requireGuid": false,
|
||||
"guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid",
|
||||
"guidParam": "guid",
|
||||
"guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换(留空表示不启用 GUID)",
|
||||
"guidValue": ""
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/* 贪蛇出动 greedy_snake 样式(tool 独立维护,不与其他 tool 共用) */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
overflow: hidden;
|
||||
background: #1c2450;
|
||||
touch-action: none;
|
||||
overscroll-behavior: none;
|
||||
-webkit-user-select: none; user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
}
|
||||
#gs-root { position: fixed; inset: 0; }
|
||||
#gs-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
.gs-hidden { display: none !important; }
|
||||
|
||||
/* ---------- 左上 HUD ---------- */
|
||||
#gs-hud-stats {
|
||||
position: absolute; top: calc(10px + env(safe-area-inset-top)); left: 14px;
|
||||
color: #fff; font-size: 14px; line-height: 1.9; z-index: 5;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,.5);
|
||||
}
|
||||
.gs-stat-label { color: #e8ecff; }
|
||||
.gs-stat-value.coins { color: #ffd23e; font-weight: 700; }
|
||||
.gs-stat-value.lucky { color: #ff5fa2; font-weight: 700; }
|
||||
.gs-stat-value.kills { color: #ff5fa2; font-weight: 700; }
|
||||
|
||||
/* ---------- 顶部进度 ---------- */
|
||||
#gs-hud-progress {
|
||||
position: absolute; top: calc(8px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%);
|
||||
text-align: center; z-index: 5;
|
||||
}
|
||||
#gs-progress-title { color: #ffd23e; font-size: 14px; font-weight: 700; text-shadow: 0 1px 3px rgba(0,0,0,.6); }
|
||||
#gs-progress-bar-wrap { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
|
||||
#gs-progress-flame { font-size: 16px; }
|
||||
#gs-progress-bar {
|
||||
width: 150px; height: 10px; border-radius: 6px;
|
||||
background: rgba(255,255,255,.25); overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,.35);
|
||||
}
|
||||
#gs-progress-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #ffb62e, #ffd23e); border-radius: 6px; transition: width .2s; }
|
||||
#gs-progress-text { color: #fff; font-size: 12px; text-shadow: 0 1px 3px rgba(0,0,0,.6); min-width: 64px; text-align: left; }
|
||||
|
||||
/* ---------- 右上排行榜 ---------- */
|
||||
#gs-hud-board {
|
||||
position: absolute; top: calc(8px + env(safe-area-inset-top)); right: 8px; width: 168px;
|
||||
background: rgba(28,36,80,.82); border-radius: 8px; z-index: 5;
|
||||
padding: 6px 8px 8px; color: #cfd6ff; font-size: 11px;
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
#gs-board-title { text-align: center; color: #fff; font-size: 13px; font-weight: 700; padding-bottom: 4px; border-bottom: 1px solid rgba(255,255,255,.15); }
|
||||
#gs-board-best { color: #8fa0ff; margin-top: 4px; }
|
||||
#gs-board-best-row { display: flex; justify-content: space-between; padding: 2px 0 4px; border-bottom: 1px solid rgba(255,255,255,.12); }
|
||||
#gs-board-best-row .best { color: #ffd23e; font-weight: 700; }
|
||||
#gs-board-list .gs-board-row { display: flex; align-items: center; gap: 4px; padding: 1.5px 0; }
|
||||
#gs-board-list .gs-board-rank { width: 16px; text-align: center; color: #9aa6e8; flex: none; }
|
||||
#gs-board-list .gs-board-rank.r1 { color: #ffd23e; }
|
||||
#gs-board-list .gs-board-rank.r2 { color: #d7dbe8; }
|
||||
#gs-board-list .gs-board-rank.r3 { color: #e8a06a; }
|
||||
#gs-board-list .gs-board-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#gs-board-list .gs-board-score { color: #fff; }
|
||||
#gs-board-list .gs-board-row.me .gs-board-name, #gs-board-list .gs-board-row.me .gs-board-score, #gs-board-list .gs-board-row.me .gs-board-rank { color: #ff5fa2; font-weight: 700; }
|
||||
|
||||
/* ---------- 摇杆 ---------- */
|
||||
#gs-joystick {
|
||||
position: absolute; left: 26px; bottom: calc(34px + env(safe-area-inset-bottom));
|
||||
width: 118px; height: 118px; border-radius: 50%;
|
||||
background: rgba(10,14,34,.55); z-index: 6;
|
||||
}
|
||||
#gs-joystick-knob {
|
||||
position: absolute; left: 50%; top: 50%; width: 56px; height: 56px; border-radius: 50%;
|
||||
background: rgba(214,220,235,.92); transform: translate(-50%, -50%);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.4);
|
||||
}
|
||||
|
||||
/* ---------- 加速按钮 ---------- */
|
||||
#gs-boost {
|
||||
position: absolute; right: 26px; bottom: calc(30px + env(safe-area-inset-bottom));
|
||||
width: 96px; height: 96px; border-radius: 50%; border: none; z-index: 6;
|
||||
background: rgba(226,230,240,.92);
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,.45);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
#gs-boost.active { background: #ffd23e; transform: scale(1.06); }
|
||||
#gs-boost.active svg path { fill: #7a4b00; }
|
||||
|
||||
/* ---------- 遮罩通用 ---------- */
|
||||
.gs-overlay {
|
||||
position: absolute; inset: 0; z-index: 20;
|
||||
background: rgba(8,10,26,.62);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
/* ---------- 道具转盘 ---------- */
|
||||
#gs-roulette { flex-direction: column; }
|
||||
#gs-roulette-title { color: #fff; font-size: 20px; font-weight: 800; letter-spacing: 2px; text-shadow: 0 2px 6px rgba(0,0,0,.6); margin-bottom: 18px; }
|
||||
#gs-roulette-body { display: flex; align-items: center; gap: 14px; }
|
||||
#gs-slot-machine {
|
||||
position: relative; padding: 20px 18px 30px;
|
||||
background: linear-gradient(180deg, #6a4bd8, #4b32b3);
|
||||
border-radius: 14px; box-shadow: 0 8px 30px rgba(0,0,0,.5);
|
||||
}
|
||||
.gs-bulbs { position: absolute; display: flex; }
|
||||
.gs-bulbs i { width: 8px; height: 8px; border-radius: 50%; background: #ffe9a3; box-shadow: 0 0 6px #ffd23e; margin: 0 9px; }
|
||||
.gs-bulbs i.off { background: #b9a5ff; box-shadow: none; }
|
||||
.gs-bulbs-top { top: 5px; left: 10px; right: 10px; justify-content: space-between; }
|
||||
.gs-bulbs-bottom { bottom: 5px; left: 10px; right: 10px; justify-content: space-between; }
|
||||
.gs-bulbs-left { left: 5px; top: 12px; bottom: 12px; flex-direction: column; justify-content: space-between; }
|
||||
.gs-bulbs-right { right: 5px; top: 12px; bottom: 12px; flex-direction: column; justify-content: space-between; }
|
||||
.gs-bulbs-left i, .gs-bulbs-right i { margin: 7px 0; }
|
||||
#gs-reels { display: flex; gap: 8px; }
|
||||
.gs-reel {
|
||||
width: 92px; height: 148px; border-radius: 8px; cursor: pointer;
|
||||
background: radial-gradient(circle at 50% 45%, #7fb3ff 0%, #4d7ddb 55%, #3a5cb4 100%);
|
||||
border: 2px solid rgba(255,255,255,.25);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: transform .15s, border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.gs-reel.selected { border-color: #ffd23e; box-shadow: 0 0 14px rgba(255,210,62,.8); transform: scale(1.04); }
|
||||
.gs-reel-icon { width: 62px; height: 62px; }
|
||||
.gs-reel-icon svg, .gs-reel-icon canvas { width: 100%; height: 100%; }
|
||||
#gs-roulette-skip {
|
||||
position: absolute; bottom: 7px; left: 0; right: 0; text-align: center;
|
||||
color: #e5dcff; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
#gs-reroll {
|
||||
writing-mode: vertical-rl; letter-spacing: 4px;
|
||||
background: linear-gradient(180deg, #58c8f2, #2ea7e0);
|
||||
color: #fff; border: none; border-radius: 16px;
|
||||
padding: 12px 8px; font-size: 13px; font-weight: 700; cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.4);
|
||||
}
|
||||
#gs-reroll:disabled { filter: grayscale(.6); opacity: .7; }
|
||||
#gs-reroll .gs-gem { writing-mode: horizontal-tb; margin-bottom: 4px; }
|
||||
|
||||
/* ---------- 复活面板 ---------- */
|
||||
#gs-revive-panel {
|
||||
position: relative; width: min(430px, 92vw);
|
||||
background: rgba(10,12,24,.92); border-radius: 14px;
|
||||
padding: 26px 20px 24px; text-align: center;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,.6);
|
||||
}
|
||||
#gs-revive-close {
|
||||
position: absolute; top: 8px; right: 12px; background: none; border: none;
|
||||
color: #fff; font-size: 20px; cursor: pointer;
|
||||
}
|
||||
#gs-revive-title { color: #ffd23e; font-size: 16px; font-weight: 700; }
|
||||
#gs-revive-title span { color: #ff8a3e; }
|
||||
#gs-revive-count-wrap { position: relative; width: 74px; height: 74px; margin: 18px auto 16px; }
|
||||
#gs-revive-count-ring {
|
||||
position: absolute; inset: 0; border-radius: 50%;
|
||||
background: conic-gradient(#ffd23e var(--p, 100%), rgba(255,255,255,.15) 0);
|
||||
-webkit-mask: radial-gradient(circle, transparent 55%, #000 56%);
|
||||
mask: radial-gradient(circle, transparent 55%, #000 56%);
|
||||
}
|
||||
#gs-revive-count {
|
||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-size: 30px; font-weight: 800;
|
||||
}
|
||||
#gs-revive-btn {
|
||||
background: linear-gradient(180deg, #ffb62e, #ff8a1e);
|
||||
color: #fff; border: none; border-radius: 8px;
|
||||
padding: 10px 26px; font-size: 15px; font-weight: 700; cursor: pointer;
|
||||
box-shadow: 0 4px 14px rgba(255,150,30,.45);
|
||||
}
|
||||
#gs-revive-btn:disabled { filter: grayscale(.7); opacity: .7; }
|
||||
|
||||
/* ---------- 结算面板 ---------- */
|
||||
#gs-result-panel {
|
||||
width: min(430px, 92vw); background: rgba(10,12,24,.92); border-radius: 14px;
|
||||
padding: 22px 20px; text-align: center; box-shadow: 0 10px 40px rgba(0,0,0,.6);
|
||||
}
|
||||
#gs-result-title { color: #fff; font-size: 18px; font-weight: 800; letter-spacing: 2px; }
|
||||
.gs-result-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 18px 0 10px; }
|
||||
.gs-result-cell { background: rgba(255,255,255,.06); border-radius: 10px; padding: 10px 0; }
|
||||
.gs-result-num { font-size: 22px; font-weight: 800; color: #fff; }
|
||||
.gs-result-num.coins { color: #ffd23e; }
|
||||
.gs-result-num.kills { color: #ff5fa2; }
|
||||
.gs-result-num.lucky { color: #ff5fa2; }
|
||||
.gs-result-label { color: #9aa6e8; font-size: 12px; margin-top: 2px; }
|
||||
#gs-result-best { color: #8fa0ff; font-size: 13px; margin-bottom: 16px; }
|
||||
#gs-result-best em { color: #ffd23e; font-style: normal; font-weight: 700; }
|
||||
#gs-result-btns { display: flex; gap: 12px; justify-content: center; }
|
||||
.gs-btn-primary {
|
||||
background: linear-gradient(180deg, #ffb62e, #ff8a1e); color: #fff; border: none;
|
||||
border-radius: 8px; padding: 10px 30px; font-size: 15px; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.gs-btn-ghost {
|
||||
background: rgba(255,255,255,.12); color: #cfd6ff; border: 1px solid rgba(255,255,255,.25);
|
||||
border-radius: 8px; padding: 10px 22px; font-size: 15px; cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------- Toast ---------- */
|
||||
#gs-toast {
|
||||
position: absolute; left: 50%; top: 18%; transform: translateX(-50%);
|
||||
padding: 8px 18px; border-radius: 18px; z-index: 40;
|
||||
color: #fff; font-size: 13px; pointer-events: none;
|
||||
background: rgba(46,120,220,.92);
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,.4);
|
||||
}
|
||||
#gs-toast.success { background: rgba(46,180,90,.92); }
|
||||
#gs-toast.error { background: rgba(220,60,60,.92); }
|
||||
|
||||
/* ---------- 交互详情弹窗 ---------- */
|
||||
#gs-dialog { position: absolute; inset: 0; z-index: 50; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; }
|
||||
#gs-dialog-box { width: min(560px, 94vw); max-height: 70vh; background: #10142c; border: 1px solid #3a4a8c; border-radius: 10px; display: flex; flex-direction: column; }
|
||||
#gs-dialog-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid #2a3568; color: #fff; font-size: 13px; }
|
||||
#gs-dialog-head #gs-dialog-title { flex: 1; font-weight: 700; }
|
||||
#gs-dialog-head button { background: #2a3568; color: #cfd6ff; border: none; border-radius: 6px; padding: 3px 10px; font-size: 12px; cursor: pointer; }
|
||||
#gs-dialog-body { margin: 0; padding: 10px 12px; overflow: auto; color: #9fe8b8; font-size: 11px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
/* 横屏适配:竖屏时提示旋转 */
|
||||
#gs-rotate-tip {
|
||||
position: absolute; inset: 0; z-index: 60; background: #1c2450; color: #cfd6ff;
|
||||
display: none; align-items: center; justify-content: center; text-align: center;
|
||||
font-size: 15px; padding: 0 30px;
|
||||
}
|
||||
@media (orientation: portrait) and (max-width: 520px) {
|
||||
#gs-rotate-tip { display: flex; }
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>贪蛇出动</title>
|
||||
<link rel="stylesheet" href="greedy_snake.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="gs-root">
|
||||
<canvas id="gs-canvas"></canvas>
|
||||
|
||||
<!-- 左上 HUD:金币 / 幸运星 / 击杀 -->
|
||||
<div id="gs-hud-stats" class="gs-hidden">
|
||||
<div class="gs-stat-row"><span class="gs-stat-label">金币:</span><span id="gs-stat-coins" class="gs-stat-value coins">0</span></div>
|
||||
<div class="gs-stat-row"><span class="gs-stat-label">幸运星:</span><span id="gs-stat-lucky" class="gs-stat-value lucky">0</span></div>
|
||||
<div class="gs-stat-row"><span class="gs-stat-label">击杀:</span><span id="gs-stat-kills" class="gs-stat-value kills">0</span></div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部进度:长度达到 N 进入宝藏屋 -->
|
||||
<div id="gs-hud-progress" class="gs-hidden">
|
||||
<div id="gs-progress-title">长度达到<span id="gs-progress-target">2000</span>进入宝藏屋</div>
|
||||
<div id="gs-progress-bar-wrap">
|
||||
<span id="gs-progress-flame">🔥</span>
|
||||
<div id="gs-progress-bar"><div id="gs-progress-fill"></div></div>
|
||||
<span id="gs-progress-text">0/2000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右上排行榜 -->
|
||||
<div id="gs-hud-board" class="gs-hidden">
|
||||
<div id="gs-board-title">排行榜</div>
|
||||
<div id="gs-board-best">今日最佳</div>
|
||||
<div id="gs-board-best-row"><span class="gs-board-name best">🏅 <em id="gs-best-name">-</em></span><span class="gs-board-score best" id="gs-best-score">0</span></div>
|
||||
<div id="gs-board-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- 左下摇杆 -->
|
||||
<div id="gs-joystick" class="gs-hidden">
|
||||
<div id="gs-joystick-knob"></div>
|
||||
</div>
|
||||
|
||||
<!-- 右下加速按钮 -->
|
||||
<button id="gs-boost" class="gs-hidden" aria-label="加速">
|
||||
<svg viewBox="0 0 24 24" width="46" height="46"><path d="M13 2 4.5 13.5 H11 L9.5 22 19 10 H12.5 Z" fill="#5b6472"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- 开局道具转盘 -->
|
||||
<div id="gs-roulette" class="gs-overlay">
|
||||
<div id="gs-roulette-title">请选择1个道具</div>
|
||||
<div id="gs-roulette-body">
|
||||
<div id="gs-slot-machine">
|
||||
<div class="gs-bulbs gs-bulbs-top"></div>
|
||||
<div class="gs-bulbs gs-bulbs-bottom"></div>
|
||||
<div class="gs-bulbs gs-bulbs-left"></div>
|
||||
<div class="gs-bulbs gs-bulbs-right"></div>
|
||||
<div id="gs-reels">
|
||||
<div class="gs-reel" data-idx="0"><div class="gs-reel-icon"></div></div>
|
||||
<div class="gs-reel" data-idx="1"><div class="gs-reel-icon"></div></div>
|
||||
<div class="gs-reel" data-idx="2"><div class="gs-reel-icon"></div></div>
|
||||
</div>
|
||||
<div id="gs-roulette-skip">不使用,直接开始</div>
|
||||
</div>
|
||||
<button id="gs-reroll"><span class="gs-gem">💎</span><span id="gs-reroll-text">20 换一换</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 死亡复活面板 -->
|
||||
<div id="gs-revive" class="gs-overlay gs-hidden">
|
||||
<div id="gs-revive-panel">
|
||||
<button id="gs-revive-close" aria-label="关闭">×</button>
|
||||
<div id="gs-revive-title">复活,给<span id="gs-revive-killer">对手</span>一点教训</div>
|
||||
<div id="gs-revive-count-wrap"><div id="gs-revive-count-ring"></div><span id="gs-revive-count">5</span></div>
|
||||
<button id="gs-revive-btn">▶ 免费复活</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结算面板 -->
|
||||
<div id="gs-result" class="gs-overlay gs-hidden">
|
||||
<div id="gs-result-panel">
|
||||
<div id="gs-result-title">本局结算</div>
|
||||
<div class="gs-result-grid">
|
||||
<div class="gs-result-cell"><div class="gs-result-num" id="gs-result-score">0</div><div class="gs-result-label">本局长度</div></div>
|
||||
<div class="gs-result-cell"><div class="gs-result-num coins" id="gs-result-coins">0</div><div class="gs-result-label">本局金币</div></div>
|
||||
<div class="gs-result-cell"><div class="gs-result-num kills" id="gs-result-kills">0</div><div class="gs-result-label">击杀</div></div>
|
||||
<div class="gs-result-cell"><div class="gs-result-num lucky" id="gs-result-lucky">0</div><div class="gs-result-label">幸运星</div></div>
|
||||
</div>
|
||||
<div id="gs-result-best">历史最佳长度:<em id="gs-result-best-num">0</em></div>
|
||||
<div id="gs-result-btns">
|
||||
<button id="gs-result-again" class="gs-btn-primary">再来一局</button>
|
||||
<button id="gs-result-home" class="gs-btn-ghost">重选道具</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 竖屏提示 -->
|
||||
<div id="gs-rotate-tip"> 请旋转手机至横屏体验「贪蛇出动」</div>
|
||||
|
||||
<!-- Toast / 交互详情弹窗(双模式) -->
|
||||
<div id="gs-toast" class="gs-hidden"></div>
|
||||
<div id="gs-dialog" class="gs-hidden">
|
||||
<div id="gs-dialog-box">
|
||||
<div id="gs-dialog-head"><span id="gs-dialog-title">交互详情</span><button id="gs-dialog-copy">复制</button><button id="gs-dialog-close">×</button></div>
|
||||
<pre id="gs-dialog-body"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/utils.js"></script>
|
||||
<script src="js/input.js"></script>
|
||||
<script src="js/snake.js"></script>
|
||||
<script src="js/ai.js"></script>
|
||||
<script src="js/world.js"></script>
|
||||
<script src="js/render.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,101 @@
|
||||
/* 贪蛇出动 - AI 蛇控制器:觅食/避障/边界回避/随机加速 */
|
||||
(function () {
|
||||
'use strict';
|
||||
window.GS = window.GS || {};
|
||||
const U = GS.U;
|
||||
|
||||
class AI {
|
||||
constructor(snake) {
|
||||
this.s = snake;
|
||||
this.decideTimer = U.rand(0, 0.3);
|
||||
this.wanderAngle = snake.angle;
|
||||
this.boostTimer = 0;
|
||||
}
|
||||
|
||||
update(dt, world, snakes) {
|
||||
const s = this.s;
|
||||
if (s.dead) return;
|
||||
this.decideTimer -= dt;
|
||||
this.boostTimer -= dt;
|
||||
if (this.decideTimer > 0) {
|
||||
s.boosting = this.boostTimer > 0;
|
||||
return;
|
||||
}
|
||||
this.decideTimer = 0.15;
|
||||
|
||||
const h = s.head;
|
||||
const look = 90 + s.radius * 3;
|
||||
const aheadX = h.x + Math.cos(s.angle) * look;
|
||||
const aheadY = h.y + Math.sin(s.angle) * look;
|
||||
|
||||
// 1) 边界回避
|
||||
const margin = 220;
|
||||
let avoid = null;
|
||||
if (aheadX < margin || aheadY < margin || aheadX > world.size - margin || aheadY > world.size - margin) {
|
||||
avoid = Math.atan2(world.size / 2 - h.y, world.size / 2 - h.x);
|
||||
}
|
||||
|
||||
// 2) 蛇身回避:检查前方采样点是否撞上其他蛇
|
||||
if (avoid == null) {
|
||||
for (const o of snakes) {
|
||||
if (o === s || o.dead) continue;
|
||||
const segs = o.segments();
|
||||
const rr = (s.radius + o.radius + 26);
|
||||
for (let i = 0; i < segs.length; i += 2) {
|
||||
if (U.dist2(aheadX, aheadY, segs[i].x, segs[i].y) < rr * rr) {
|
||||
// 向左或向右转,选更开阔的一侧
|
||||
const left = s.angle - Math.PI / 2, right = s.angle + Math.PI / 2;
|
||||
avoid = this._clearer(left, right, h, snakes, s);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (avoid != null) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (avoid != null) {
|
||||
s.targetAngle = avoid;
|
||||
s.boosting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) 觅食:找最近金币
|
||||
let best = null, bestD = 520 * 520;
|
||||
const near = world.grid.query(h.x, h.y, 520);
|
||||
for (const c of near) {
|
||||
const d2 = U.dist2(h.x, h.y, c.x, c.y);
|
||||
if (d2 < bestD) { bestD = d2; best = c; }
|
||||
}
|
||||
if (best) {
|
||||
s.targetAngle = Math.atan2(best.y - h.y, best.x - h.x);
|
||||
} else {
|
||||
// 漫游:小幅随机偏转
|
||||
this.wanderAngle += U.rand(-0.6, 0.6);
|
||||
s.targetAngle = this.wanderAngle;
|
||||
}
|
||||
|
||||
// 4) 随机加速:追食或逃跑时小概率冲刺
|
||||
if (Math.random() < 0.06 && s.score > 30) this.boostTimer = U.rand(0.4, 1.0);
|
||||
s.boosting = this.boostTimer > 0;
|
||||
}
|
||||
|
||||
_clearer(a1, a2, h, snakes, s) {
|
||||
const score = a => {
|
||||
const px = h.x + Math.cos(a) * 160, py = h.y + Math.sin(a) * 160;
|
||||
let d = 1e9;
|
||||
for (const o of snakes) {
|
||||
if (o === s || o.dead) continue;
|
||||
const segs = o.segments();
|
||||
for (let i = 0; i < segs.length; i += 3) {
|
||||
const dd = U.dist2(px, py, segs[i].x, segs[i].y);
|
||||
if (dd < d) d = dd;
|
||||
}
|
||||
}
|
||||
return d;
|
||||
};
|
||||
return score(a1) >= score(a2) ? a1 : a2;
|
||||
}
|
||||
}
|
||||
|
||||
GS.AI = AI;
|
||||
})();
|
||||
@@ -0,0 +1,49 @@
|
||||
/* 贪蛇出动 - 全局常量配置(前端默认值,服务端 config/greedy_snake.json 可覆盖) */
|
||||
(function () {
|
||||
'use strict';
|
||||
window.GS = window.GS || {};
|
||||
|
||||
GS.CFG = {
|
||||
worldSize: 4000, // 世界边长
|
||||
botCount: 9, // AI 蛇数量
|
||||
coinCount: 320, // 场内常驻金币数
|
||||
baseSpeed: 130, // 基础移速 px/s
|
||||
boostSpeed: 250, // 加速移速 px/s
|
||||
turnRate: 4.6, // 玩家转向速度 rad/s
|
||||
botTurnRate: 3.2, // AI 转向速度 rad/s
|
||||
startScore: 10, // 初始长度分
|
||||
treasureTarget: 2000, // 宝藏屋目标
|
||||
rerollCost: 20, // 换一换钻石
|
||||
initialGems: 100, // 初始钻石
|
||||
reviveCountdown: 5, // 复活倒计时
|
||||
playerName: '贪蛇小将',
|
||||
uiMode: 'debug' // debug / normal,服务端配置优先
|
||||
};
|
||||
|
||||
// 道具定义
|
||||
GS.ITEMS = {
|
||||
helmet: { key: 'helmet', name: '头盔护盾', desc: '开局获得护盾,可抵挡一次致命碰撞' },
|
||||
rocket: { key: 'rocket', name: '火箭冲刺', desc: '开局自动冲刺 4 秒,冲刺期间无敌' },
|
||||
magnet: { key: 'magnet', name: '吸铁石', desc: '开局 20 秒内吸附附近金币' },
|
||||
expcard: { key: 'expcard', name: '体验卡', desc: '开局直接 +200 长度' }
|
||||
};
|
||||
GS.ITEM_KEYS = ['helmet', 'rocket', 'magnet', 'expcard'];
|
||||
|
||||
// 蛇配色(玩家固定红色,AI 随机)
|
||||
GS.SNAKE_COLORS = [
|
||||
{ body: '#f2f2f7', dark: '#c9c9d8' }, // 白
|
||||
{ body: '#f0c070', dark: '#c8934a' }, // 面包黄
|
||||
{ body: '#8fd0ff', dark: '#5a9cd0' }, // 浅蓝
|
||||
{ body: '#a0e8a0', dark: '#6ab86a' }, // 绿
|
||||
{ body: '#e0a0e8', dark: '#b06ab8' }, // 粉紫
|
||||
{ body: '#ffb060', dark: '#d08030' }, // 橙
|
||||
{ body: '#b8b8c8', dark: '#8a8a9c' } // 灰
|
||||
];
|
||||
GS.PLAYER_COLOR = { body: '#e8394a', dark: '#b81f33' };
|
||||
|
||||
GS.BOT_NAMES = [
|
||||
'Wanderer', 'MILu', '夜行者', '黑白的熊猫', '木头宇', '星空',
|
||||
'态度', '林小八', 'Angel', '疾风少年', '小旋风', '贪吃鬼',
|
||||
'史蒂文', '米奇的k夏', '石家庄一起快乐', '不记得了'
|
||||
];
|
||||
})();
|
||||
@@ -0,0 +1,109 @@
|
||||
/* 贪蛇出动 - 输入控制:左摇杆 + 右加速按钮 + 桌面键盘 */
|
||||
(function () {
|
||||
'use strict';
|
||||
window.GS = window.GS || {};
|
||||
|
||||
class Input {
|
||||
constructor() {
|
||||
this.angle = -Math.PI / 2; // 目标方向
|
||||
this.boost = false;
|
||||
this.enabled = false;
|
||||
this._joyId = null;
|
||||
this._joyBase = { x: 0, y: 0 };
|
||||
this._keys = new Set();
|
||||
this._bind();
|
||||
}
|
||||
|
||||
_bind() {
|
||||
const joy = document.getElementById('gs-joystick');
|
||||
const knob = document.getElementById('gs-joystick-knob');
|
||||
const boostBtn = document.getElementById('gs-boost');
|
||||
this.joyEl = joy; this.knobEl = knob; this.boostEl = boostBtn;
|
||||
|
||||
// 摇杆:触摸左下区域
|
||||
joy.addEventListener('touchstart', e => {
|
||||
e.preventDefault();
|
||||
const t = e.changedTouches[0];
|
||||
this._joyId = t.identifier;
|
||||
const r = joy.getBoundingClientRect();
|
||||
this._joyBase = { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
||||
this._moveKnob(t.clientX, t.clientY);
|
||||
}, { passive: false });
|
||||
joy.addEventListener('touchmove', e => {
|
||||
e.preventDefault();
|
||||
for (const t of e.changedTouches) if (t.identifier === this._joyId) this._moveKnob(t.clientX, t.clientY);
|
||||
}, { passive: false });
|
||||
const endJoy = e => {
|
||||
for (const t of e.changedTouches) if (t.identifier === this._joyId) {
|
||||
this._joyId = null;
|
||||
this.knobEl.style.transform = 'translate(-50%, -50%)';
|
||||
}
|
||||
};
|
||||
joy.addEventListener('touchend', endJoy);
|
||||
joy.addEventListener('touchcancel', endJoy);
|
||||
|
||||
// 桌面鼠标拖拽摇杆
|
||||
let mouseDown = false;
|
||||
joy.addEventListener('mousedown', e => {
|
||||
mouseDown = true;
|
||||
const r = joy.getBoundingClientRect();
|
||||
this._joyBase = { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
||||
this._moveKnob(e.clientX, e.clientY);
|
||||
});
|
||||
window.addEventListener('mousemove', e => { if (mouseDown) this._moveKnob(e.clientX, e.clientY); });
|
||||
window.addEventListener('mouseup', () => { mouseDown = false; this.knobEl.style.transform = 'translate(-50%, -50%)'; });
|
||||
|
||||
// 加速按钮:按住加速
|
||||
const boostOn = e => { e.preventDefault(); this.boost = true; boostBtn.classList.add('active'); };
|
||||
const boostOff = () => { this.boost = false; boostBtn.classList.remove('active'); };
|
||||
boostBtn.addEventListener('touchstart', boostOn, { passive: false });
|
||||
boostBtn.addEventListener('touchend', boostOff);
|
||||
boostBtn.addEventListener('touchcancel', boostOff);
|
||||
boostBtn.addEventListener('mousedown', boostOn);
|
||||
boostBtn.addEventListener('mouseup', boostOff);
|
||||
boostBtn.addEventListener('mouseleave', boostOff);
|
||||
|
||||
// 键盘(桌面调试):方向键/WSAD 转向,空格加速
|
||||
window.addEventListener('keydown', e => {
|
||||
this._keys.add(e.code);
|
||||
if (e.code === 'Space') { this.boost = true; boostBtn.classList.add('active'); }
|
||||
this._applyKeys();
|
||||
});
|
||||
window.addEventListener('keyup', e => {
|
||||
this._keys.delete(e.code);
|
||||
if (e.code === 'Space') { this.boost = false; boostBtn.classList.remove('active'); }
|
||||
this._applyKeys();
|
||||
});
|
||||
|
||||
// 阻止页面滚动/双击缩放
|
||||
document.addEventListener('touchmove', e => { e.preventDefault(); }, { passive: false });
|
||||
document.addEventListener('gesturestart', e => e.preventDefault());
|
||||
}
|
||||
|
||||
_applyKeys() {
|
||||
let dx = 0, dy = 0;
|
||||
if (this._keys.has('ArrowLeft') || this._keys.has('KeyA')) dx -= 1;
|
||||
if (this._keys.has('ArrowRight') || this._keys.has('KeyD')) dx += 1;
|
||||
if (this._keys.has('ArrowUp') || this._keys.has('KeyW')) dy -= 1;
|
||||
if (this._keys.has('ArrowDown') || this._keys.has('KeyS')) dy += 1;
|
||||
if (dx || dy) this.angle = Math.atan2(dy, dx);
|
||||
}
|
||||
|
||||
_moveKnob(cx, cy) {
|
||||
const maxR = 40;
|
||||
let dx = cx - this._joyBase.x, dy = cy - this._joyBase.y;
|
||||
const d = Math.sqrt(dx * dx + dy * dy);
|
||||
if (d > maxR) { dx = dx / d * maxR; dy = dy / d * maxR; }
|
||||
this.knobEl.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
|
||||
if (d > 8) this.angle = Math.atan2(dy, dx);
|
||||
}
|
||||
|
||||
setVisible(v) {
|
||||
this.enabled = v;
|
||||
this.joyEl.classList.toggle('gs-hidden', !v);
|
||||
this.boostEl.classList.toggle('gs-hidden', !v);
|
||||
}
|
||||
}
|
||||
|
||||
GS.Input = Input;
|
||||
})();
|
||||
@@ -0,0 +1,337 @@
|
||||
/* 贪蛇出动 - 主控:状态机 / 游戏循环 / 击杀与死亡 / 复活 / 服务端数据同步 */
|
||||
(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();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,198 @@
|
||||
/* 贪蛇出动 - 渲染:Canvas 程序化绘制(背景网格/边界/金币/蛇/特效) */
|
||||
(function () {
|
||||
'use strict';
|
||||
window.GS = window.GS || {};
|
||||
const U = GS.U;
|
||||
|
||||
class Render {
|
||||
constructor(canvas) {
|
||||
this.cv = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.w = 0; this.h = 0; this.dpr = 1;
|
||||
this.resize();
|
||||
window.addEventListener('resize', () => this.resize());
|
||||
}
|
||||
|
||||
resize() {
|
||||
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
this.w = window.innerWidth;
|
||||
this.h = window.innerHeight;
|
||||
this.cv.width = Math.floor(this.w * this.dpr);
|
||||
this.cv.height = Math.floor(this.h * this.dpr);
|
||||
}
|
||||
|
||||
draw(world, snakes, player, now) {
|
||||
const ctx = this.ctx;
|
||||
const cam = world.cam;
|
||||
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||||
|
||||
// 背景
|
||||
ctx.fillStyle = '#222b57';
|
||||
ctx.fillRect(0, 0, this.w, this.h);
|
||||
|
||||
const sc = cam.scale;
|
||||
const ox = this.w / 2 - cam.x * sc;
|
||||
const oy = this.h / 2 - cam.y * sc;
|
||||
const toX = x => x * sc + ox;
|
||||
const toY = y => y * sc + oy;
|
||||
|
||||
// 网格
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||||
ctx.lineWidth = 1;
|
||||
const gridStep = 100 * sc;
|
||||
const gx0 = ((ox % gridStep) + gridStep) % gridStep;
|
||||
const gy0 = ((oy % gridStep) + gridStep) % gridStep;
|
||||
ctx.beginPath();
|
||||
for (let x = gx0; x < this.w; x += gridStep) { ctx.moveTo(x, 0); ctx.lineTo(x, this.h); }
|
||||
for (let y = gy0; y < this.h; y += gridStep) { ctx.moveTo(0, y); ctx.lineTo(this.w, y); }
|
||||
ctx.stroke();
|
||||
|
||||
// 边界(红粉色发光线)+ 界外压暗
|
||||
const bx0 = toX(0), by0 = toY(0), bx1 = toX(world.size), by1 = toY(world.size);
|
||||
ctx.fillStyle = 'rgba(8,10,26,0.55)';
|
||||
if (bx0 > 0) ctx.fillRect(0, 0, bx0, this.h);
|
||||
if (bx1 < this.w) ctx.fillRect(bx1, 0, this.w - bx1, this.h);
|
||||
if (by0 > 0) ctx.fillRect(0, 0, this.w, by0);
|
||||
if (by1 < this.h) ctx.fillRect(0, by1, this.w, this.h - by1);
|
||||
ctx.strokeStyle = '#ff4d6d';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.shadowColor = '#ff4d6d';
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.strokeRect(bx0, by0, bx1 - bx0, by1 - by0);
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 金币(视口内)
|
||||
for (const c of world.coins) {
|
||||
const x = toX(c.x), y = toY(c.y);
|
||||
if (x < -40 || y < -40 || x > this.w + 40 || y > this.h + 40) continue;
|
||||
const pulse = 1 + Math.sin(now / 300 + c.phase) * 0.12;
|
||||
const r = c.r * sc * pulse;
|
||||
if (c.kind === 1) this._starCoin(ctx, x, y, r);
|
||||
else this._coin(ctx, x, y, r, c.kind === 2);
|
||||
}
|
||||
|
||||
// 蛇:先画 AI 再画玩家
|
||||
for (const s of snakes) if (!s.isPlayer && !s.dead) this._snake(ctx, s, toX, toY, sc, now);
|
||||
if (player && !player.dead) this._snake(ctx, player, toX, toY, sc, now);
|
||||
}
|
||||
|
||||
_coin(ctx, x, y, r, big) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = big ? '#ffca28' : '#f5b921';
|
||||
ctx.fill();
|
||||
ctx.lineWidth = Math.max(1, r * 0.28);
|
||||
ctx.strokeStyle = big ? '#c98f00' : '#b8860b';
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.arc(x - r * 0.25, y - r * 0.25, r * 0.4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
_starCoin(ctx, x, y, r) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#ffc233';
|
||||
ctx.fill();
|
||||
ctx.lineWidth = Math.max(1, r * 0.22);
|
||||
ctx.strokeStyle = '#c98f00';
|
||||
ctx.stroke();
|
||||
// 五角星
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const rr = i % 2 === 0 ? r * 0.62 : r * 0.28;
|
||||
const a = -Math.PI / 2 + i * Math.PI / 5;
|
||||
const px = Math.cos(a) * rr, py = Math.sin(a) * rr;
|
||||
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = '#fff3c4';
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
_snake(ctx, s, toX, toY, sc, now) {
|
||||
const segs = s.segments();
|
||||
const r = s.radius * sc;
|
||||
const wob = Math.sin(s.wobble) * 0.06;
|
||||
|
||||
// 磁铁光环
|
||||
if (now < s.magnetUntil) {
|
||||
const h = segs[0];
|
||||
ctx.beginPath();
|
||||
ctx.arc(toX(h.x), toY(h.y), 260 * sc, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = 'rgba(90,200,255,0.25)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
// 护盾圈
|
||||
if (s.shield || s.invincible(now)) {
|
||||
const h = segs[0];
|
||||
const sr = r * 3.2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(toX(h.x), toY(h.y), sr, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(80,160,255,0.10)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(120,200,255,0.8)';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
// 加速尾焰
|
||||
if (s.boosting && segs.length > 2) {
|
||||
const t = segs[segs.length - 1];
|
||||
ctx.beginPath();
|
||||
ctx.arc(toX(t.x), toY(t.y), r * 0.8, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(255,220,120,0.35)';
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 身体:从尾到头
|
||||
for (let i = segs.length - 1; i >= 1; i--) {
|
||||
const g = segs[i];
|
||||
const rr = r * (1 - (i / segs.length) * 0.25) * (1 + wob * Math.sin(i * 0.6));
|
||||
ctx.beginPath();
|
||||
ctx.arc(toX(g.x), toY(g.y), rr, 0, Math.PI * 2);
|
||||
ctx.fillStyle = (s.skin === 1 && i % 4 < 2) ? s.color.dark : s.color.body;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 头
|
||||
const h = segs[0];
|
||||
const hx = toX(h.x), hy = toY(h.y);
|
||||
ctx.beginPath();
|
||||
ctx.arc(hx, hy, r * 1.08, 0, Math.PI * 2);
|
||||
ctx.fillStyle = s.color.body;
|
||||
ctx.fill();
|
||||
|
||||
// 眼睛:朝向前方
|
||||
const a = s.angle;
|
||||
const ex = Math.cos(a), ey = Math.sin(a);
|
||||
const px = -ey, py = ex;
|
||||
for (const side of [1, -1]) {
|
||||
const cx = hx + (ex * 0.45 + px * 0.5 * side) * r;
|
||||
const cy = hy + (ey * 0.45 + py * 0.5 * side) * r;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r * 0.34, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx + ex * r * 0.12, cy + ey * r * 0.12, r * 0.15, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#111';
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 名字 + 分数
|
||||
ctx.font = `${Math.max(11, 13 * sc)}px "PingFang SC", sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.92)';
|
||||
ctx.fillText(String(Math.floor(s.score)), hx, hy - r * 2.4);
|
||||
ctx.fillStyle = s.isPlayer ? 'rgba(255,255,255,0.75)' : 'rgba(200,210,255,0.7)';
|
||||
ctx.fillText(s.name, hx, hy - r * 2.4 + 14 * sc);
|
||||
}
|
||||
}
|
||||
|
||||
GS.Render = Render;
|
||||
})();
|
||||
@@ -0,0 +1,112 @@
|
||||
/* 贪蛇出动 - 蛇实体:移动/转向/生长/路径采样 */
|
||||
(function () {
|
||||
'use strict';
|
||||
window.GS = window.GS || {};
|
||||
const U = GS.U;
|
||||
|
||||
class Snake {
|
||||
constructor(opts) {
|
||||
this.id = opts.id;
|
||||
this.name = opts.name;
|
||||
this.isPlayer = !!opts.isPlayer;
|
||||
this.color = opts.color;
|
||||
this.skin = opts.skin || 0; // 0 纯色 1 条纹
|
||||
this.angle = opts.angle != null ? opts.angle : U.rand(0, Math.PI * 2);
|
||||
this.targetAngle = this.angle;
|
||||
this.turnRate = this.isPlayer ? GS.CFG.turnRate : GS.CFG.botTurnRate;
|
||||
this.score = opts.score || GS.CFG.startScore;
|
||||
this.dead = false;
|
||||
this.boosting = false;
|
||||
this.shield = false; // 护盾:抵挡一次致命碰撞
|
||||
this.invincibleUntil = 0; // 无敌时间戳(火箭/复活保护)
|
||||
this.magnetUntil = 0; // 磁铁截止
|
||||
this.kills = 0;
|
||||
this.coins = 0; // 本局吃到的金币数
|
||||
this.lucky = 0; // 本局幸运星
|
||||
// 路径:密集头轨迹点
|
||||
this.path = [{ x: opts.x, y: opts.y }];
|
||||
this.pathLen = 0;
|
||||
this._segs = [];
|
||||
this._segsDirty = true;
|
||||
this.wobble = U.rand(0, Math.PI * 2); // 身体摆动相位
|
||||
}
|
||||
|
||||
get head() { return this.path[this.path.length - 1]; }
|
||||
|
||||
// 半径随分数增长
|
||||
get radius() { return U.clamp(7 + Math.sqrt(Math.max(0, this.score)) * 0.32, 7, 30); }
|
||||
|
||||
// 身体节数
|
||||
get segCount() { return Math.floor(10 + this.score / 6); }
|
||||
|
||||
get spacing() { return Math.max(5, this.radius * 0.5); }
|
||||
|
||||
get speed() { return this.boosting ? GS.CFG.boostSpeed : GS.CFG.baseSpeed; }
|
||||
|
||||
invincible(now) { return now < this.invincibleUntil; }
|
||||
|
||||
update(dt, now) {
|
||||
if (this.dead) return;
|
||||
// 转向限速
|
||||
const da = U.normAngle(this.targetAngle - this.angle);
|
||||
const maxTurn = this.turnRate * dt;
|
||||
this.angle += U.clamp(da, -maxTurn, maxTurn);
|
||||
|
||||
const sp = this.speed;
|
||||
const h = this.head;
|
||||
const nx = h.x + Math.cos(this.angle) * sp * dt;
|
||||
const ny = h.y + Math.sin(this.angle) * sp * dt;
|
||||
this.path.push({ x: nx, y: ny });
|
||||
this.pathLen += sp * dt;
|
||||
|
||||
// 裁剪路径:保留足够长度
|
||||
const need = this.segCount * this.spacing + this.radius * 4;
|
||||
while (this.path.length > 2 && this.pathLen > need) {
|
||||
const d = U.dist(this.path[0].x, this.path[0].y, this.path[1].x, this.path[1].y);
|
||||
if (this.pathLen - d < need) break;
|
||||
this.pathLen -= d;
|
||||
this.path.shift();
|
||||
}
|
||||
this._segsDirty = true;
|
||||
this.wobble += dt * (this.boosting ? 14 : 9);
|
||||
}
|
||||
|
||||
// 从头部沿路径每隔 spacing 采样一节(不修改原路径)
|
||||
segments() {
|
||||
if (!this._segsDirty && this._segs.length) return this._segs;
|
||||
const segs = [{ x: this.head.x, y: this.head.y }];
|
||||
const n = this.segCount;
|
||||
const spacing = this.spacing;
|
||||
let acc = 0; // 自上一个采样点起已走过但不足 spacing 的距离
|
||||
for (let i = this.path.length - 1; i > 0 && segs.length < n; i--) {
|
||||
let ax = this.path[i].x, ay = this.path[i].y;
|
||||
const b = this.path[i - 1];
|
||||
let d = U.dist(ax, ay, b.x, b.y);
|
||||
if (d < 1e-9) continue;
|
||||
while (acc + d >= spacing && segs.length < n) {
|
||||
const t = (spacing - acc) / d;
|
||||
const px = ax + (b.x - ax) * t;
|
||||
const py = ay + (b.y - ay) * t;
|
||||
segs.push({ x: px, y: py });
|
||||
d -= (spacing - acc);
|
||||
ax = px; ay = py;
|
||||
acc = 0;
|
||||
}
|
||||
acc += d;
|
||||
}
|
||||
this._segs = segs;
|
||||
this._segsDirty = false;
|
||||
return segs;
|
||||
}
|
||||
|
||||
grow(v) { this.score += v; }
|
||||
|
||||
// 死亡:返回尸体坐标(用于掉币)
|
||||
die() {
|
||||
this.dead = true;
|
||||
return this.segments().slice();
|
||||
}
|
||||
}
|
||||
|
||||
GS.Snake = Snake;
|
||||
})();
|
||||
@@ -0,0 +1,213 @@
|
||||
/* 贪蛇出动 - UI:HUD 刷新 / 道具转盘 / 复活 / 结算 / Toast 与交互详情弹窗(双模式) */
|
||||
(function () {
|
||||
'use strict';
|
||||
window.GS = window.GS || {};
|
||||
const U = GS.U;
|
||||
|
||||
/* ---------- 道具图标:程序化 SVG ---------- */
|
||||
function itemSvg(key) {
|
||||
switch (key) {
|
||||
case 'helmet':
|
||||
return '<svg viewBox="0 0 64 64"><path d="M10 38a22 22 0 0 1 44 0v6H36l-4-8-4 8H10z" fill="#e04a3a"/><path d="M14 38a18 18 0 0 1 36 0" fill="none" stroke="#fff" stroke-width="3" opacity=".55"/><rect x="8" y="42" width="48" height="6" rx="3" fill="#b83224"/></svg>';
|
||||
case 'rocket':
|
||||
return '<svg viewBox="0 0 64 64"><path d="M32 4c8 8 10 20 10 28l-10 10-10-10c0-8 2-20 10-28z" fill="#e8ecf5"/><circle cx="32" cy="24" r="5" fill="#5a8dd0"/><path d="M22 32l-8 12 10-2zM42 32l8 12-10-2z" fill="#e04a3a"/><path d="M28 44l4 12 4-12z" fill="#ffb62e"/></svg>';
|
||||
case 'magnet':
|
||||
return '<svg viewBox="0 0 64 64"><path d="M18 12v20a14 14 0 0 0 28 0V12H36v20a4 4 0 0 1-8 0V12z" fill="#e04a3a"/><rect x="18" y="12" width="10" height="10" fill="#e8ecf5"/><rect x="36" y="12" width="10" height="10" fill="#e8ecf5"/></svg>';
|
||||
case 'expcard':
|
||||
return '<svg viewBox="0 0 64 64"><rect x="8" y="16" width="48" height="32" rx="5" fill="#ffd23e"/><rect x="8" y="16" width="48" height="32" rx="5" fill="none" stroke="#c98f00" stroke-width="2"/><circle cx="24" cy="32" r="8" fill="#fff3c4"/><path d="M38 26h12M38 32h12M38 38h8" stroke="#c98f00" stroke-width="3" stroke-linecap="round"/></svg>';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
class UI {
|
||||
constructor() {
|
||||
this.el = id => document.getElementById(id);
|
||||
this.statsEl = this.el('gs-hud-stats');
|
||||
this.coinsEl = this.el('gs-stat-coins');
|
||||
this.luckyEl = this.el('gs-stat-lucky');
|
||||
this.killsEl = this.el('gs-stat-kills');
|
||||
this.progEl = this.el('gs-hud-progress');
|
||||
this.progFill = this.el('gs-progress-fill');
|
||||
this.progText = this.el('gs-progress-text');
|
||||
this.boardEl = this.el('gs-hud-board');
|
||||
this.boardList = this.el('gs-board-list');
|
||||
this.bestName = this.el('gs-best-name');
|
||||
this.bestScore = this.el('gs-best-score');
|
||||
this.rouletteEl = this.el('gs-roulette');
|
||||
this.reviveEl = this.el('gs-revive');
|
||||
this.resultEl = this.el('gs-result');
|
||||
this.toastEl = this.el('gs-toast');
|
||||
this.dlgEl = this.el('gs-dialog');
|
||||
this._bulbs();
|
||||
this._toastTimer = null;
|
||||
this._dlgTimer = null;
|
||||
}
|
||||
|
||||
/* 转盘灯泡装饰 */
|
||||
_bulbs() {
|
||||
document.querySelectorAll('.gs-bulbs').forEach(box => {
|
||||
const vert = box.classList.contains('gs-bulbs-left') || box.classList.contains('gs-bulbs-right');
|
||||
const n = vert ? 7 : 12;
|
||||
for (let i = 0; i < n; i++) box.appendChild(document.createElement('i'));
|
||||
});
|
||||
setInterval(() => {
|
||||
document.querySelectorAll('.gs-bulbs i').forEach((b, i) => b.classList.toggle('off', (i + Math.floor(Date.now() / 400)) % 2 === 0));
|
||||
}, 400);
|
||||
}
|
||||
|
||||
setUiMode(mode) { GS.CFG.uiMode = mode; }
|
||||
|
||||
/* ---------- HUD ---------- */
|
||||
showHud(v) {
|
||||
this.statsEl.classList.toggle('gs-hidden', !v);
|
||||
this.boardEl.classList.toggle('gs-hidden', !v);
|
||||
}
|
||||
|
||||
updateStats(totalCoins, lucky, kills) {
|
||||
this.coinsEl.textContent = U.fmt(totalCoins);
|
||||
this.luckyEl.textContent = U.fmt(lucky);
|
||||
this.killsEl.textContent = U.fmt(kills);
|
||||
}
|
||||
|
||||
updateProgress(score, target, visible) {
|
||||
this.progEl.classList.toggle('gs-hidden', !visible);
|
||||
if (!visible) return;
|
||||
this.progFill.style.width = U.clamp(score / target * 100, 0, 100) + '%';
|
||||
this.progText.textContent = Math.floor(Math.min(score, target)) + '/' + target;
|
||||
}
|
||||
|
||||
updateBoard(rows, me, best) {
|
||||
if (best) {
|
||||
this.bestName.textContent = best.name || '-';
|
||||
this.bestScore.textContent = U.fmt(best.score || 0);
|
||||
}
|
||||
let html = '';
|
||||
rows.forEach((r, i) => {
|
||||
const rank = i + 1;
|
||||
const medal = rank === 1 ? '👑' : (rank <= 3 ? ['①', '②', '③'][rank - 1] : rank);
|
||||
html += `<div class="gs-board-row${r.me ? ' me' : ''}">
|
||||
<span class="gs-board-rank r${rank}">${medal}</span>
|
||||
<span class="gs-board-name">${this._esc(r.name)}</span>
|
||||
<span class="gs-board-score">${U.fmt(r.score)}</span></div>`;
|
||||
});
|
||||
this.boardList.innerHTML = html;
|
||||
}
|
||||
|
||||
_esc(s) { return String(s).replace(/[<>&"]/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c])); }
|
||||
|
||||
/* ---------- 转盘 ---------- */
|
||||
showRoulette(gems) {
|
||||
this.rouletteEl.classList.remove('gs-hidden');
|
||||
this.el('gs-reroll-text').textContent = GS.CFG.rerollCost + ' 换一换';
|
||||
this.updateGems(gems);
|
||||
}
|
||||
hideRoulette() { this.rouletteEl.classList.add('gs-hidden'); }
|
||||
updateGems(gems) {
|
||||
const btn = this.el('gs-reroll');
|
||||
btn.disabled = gems < GS.CFG.rerollCost;
|
||||
}
|
||||
setReels(keys, selected) {
|
||||
document.querySelectorAll('.gs-reel').forEach((reel, i) => {
|
||||
reel.classList.toggle('selected', selected === i);
|
||||
reel.querySelector('.gs-reel-icon').innerHTML = itemSvg(keys[i]);
|
||||
reel.title = GS.ITEMS[keys[i]].name + ':' + GS.ITEMS[keys[i]].desc;
|
||||
});
|
||||
}
|
||||
spinReels(keys) {
|
||||
// 滚动动画:快速切换图标后落定
|
||||
const reels = document.querySelectorAll('.gs-reel');
|
||||
reels.forEach((reel, i) => {
|
||||
const icon = reel.querySelector('.gs-reel-icon');
|
||||
let n = 0;
|
||||
const timer = setInterval(() => {
|
||||
icon.innerHTML = itemSvg(U.pick(GS.ITEM_KEYS));
|
||||
if (++n > 8 + i * 4) { clearInterval(timer); icon.innerHTML = itemSvg(keys[i]); }
|
||||
}, 70);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 复活 ---------- */
|
||||
showRevive(killerName, usedFree) {
|
||||
this.reviveEl.classList.remove('gs-hidden');
|
||||
this.el('gs-revive-killer').textContent = killerName || '对手';
|
||||
const btn = this.el('gs-revive-btn');
|
||||
btn.disabled = usedFree;
|
||||
btn.textContent = usedFree ? '已复活过' : '▶ 免费复活';
|
||||
}
|
||||
hideRevive() { this.reviveEl.classList.add('gs-hidden'); }
|
||||
updateReviveCount(n, pct) {
|
||||
this.el('gs-revive-count').textContent = n;
|
||||
this.el('gs-revive-count-ring').style.setProperty('--p', (pct * 100) + '%');
|
||||
}
|
||||
|
||||
/* ---------- 结算 ---------- */
|
||||
showResult(data) {
|
||||
this.resultEl.classList.remove('gs-hidden');
|
||||
this.el('gs-result-score').textContent = U.fmt(data.score);
|
||||
this.el('gs-result-coins').textContent = U.fmt(data.coins);
|
||||
this.el('gs-result-kills').textContent = U.fmt(data.kills);
|
||||
this.el('gs-result-lucky').textContent = U.fmt(data.lucky);
|
||||
this.el('gs-result-best-num').textContent = U.fmt(data.best);
|
||||
}
|
||||
hideResult() { this.resultEl.classList.add('gs-hidden'); }
|
||||
|
||||
/* ---------- Toast(normal 模式 / 强提示) ---------- */
|
||||
toast(msg, type) {
|
||||
const el = this.toastEl;
|
||||
el.textContent = msg;
|
||||
el.className = type || '';
|
||||
el.classList.remove('gs-hidden');
|
||||
clearTimeout(this._toastTimer);
|
||||
this._toastTimer = setTimeout(() => el.classList.add('gs-hidden'), 2000);
|
||||
}
|
||||
|
||||
/* ---------- 交互详情弹窗(debug 模式) ---------- */
|
||||
showDialog(info, mode) {
|
||||
// mode: view=查看类(normal 降级 toast) / error=异常(双模式保留)
|
||||
const isDebug = GS.CFG.uiMode === 'debug';
|
||||
if (!isDebug && mode === 'view') { this.toast(info.action + ' 成功', 'success'); return; }
|
||||
if (!isDebug && mode !== 'error') { this.toast(info.action + (info.ok ? ' 成功' : ' 失败'), info.ok ? 'success' : 'error'); return; }
|
||||
const body = this.el('gs-dialog-body');
|
||||
body.textContent =
|
||||
'触发动作:' + info.action + '\n' +
|
||||
'时间:' + U.nowStr() + '\n' +
|
||||
'接口地址:' + (info.url || '-') + '\n' +
|
||||
'HTTP 方法:' + (info.method || '-') + '\n' +
|
||||
'请求参数:' + (info.req || '-') + '\n' +
|
||||
'返回结果:' + (info.res || '-') + '\n' +
|
||||
'错误信息:' + (info.err || '-') + '\n' +
|
||||
'耗时:' + (info.ms != null ? info.ms + 'ms' : '-') + '\n' +
|
||||
'当前状态:' + (info.ok ? '成功' : '失败');
|
||||
this.dlgEl.classList.remove('gs-hidden');
|
||||
this._dlgInfo = info;
|
||||
clearTimeout(this._dlgTimer);
|
||||
if (mode !== 'error') this._dlgTimer = setTimeout(() => this.hideDialog(), 3000);
|
||||
}
|
||||
hideDialog() { this.dlgEl.classList.add('gs-hidden'); }
|
||||
|
||||
/* 带交互记录的 fetch 包装(双模式) */
|
||||
async trackFetch(action, url, opts) {
|
||||
const t0 = performance.now();
|
||||
let info = { action, url, method: (opts && opts.method) || 'GET', req: opts && opts.body ? opts.body : '-' };
|
||||
try {
|
||||
const res = await fetch(url, opts);
|
||||
const text = await res.text();
|
||||
info.ms = Math.round(performance.now() - t0);
|
||||
info.res = text.slice(0, 400);
|
||||
info.ok = res.ok;
|
||||
if (!res.ok) info.err = 'HTTP ' + res.status;
|
||||
this.showDialog(info, res.ok ? 'view' : 'error');
|
||||
return res.ok ? JSON.parse(text) : null;
|
||||
} catch (e) {
|
||||
info.ms = Math.round(performance.now() - t0);
|
||||
info.ok = false;
|
||||
info.err = String(e && e.message || e);
|
||||
this.showDialog(info, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GS.UI = UI;
|
||||
GS.itemSvg = itemSvg;
|
||||
})();
|
||||
@@ -0,0 +1,69 @@
|
||||
/* 贪蛇出动 - 通用工具函数 */
|
||||
(function () {
|
||||
'use strict';
|
||||
window.GS = window.GS || {};
|
||||
|
||||
const U = {};
|
||||
GS.U = U;
|
||||
|
||||
U.rand = (a, b) => a + Math.random() * (b - a);
|
||||
U.randInt = (a, b) => Math.floor(U.rand(a, b + 1));
|
||||
U.pick = arr => arr[Math.floor(Math.random() * arr.length)];
|
||||
U.clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||
U.dist2 = (x1, y1, x2, y2) => { const dx = x2 - x1, dy = y2 - y1; return dx * dx + dy * dy; };
|
||||
U.dist = (x1, y1, x2, y2) => Math.sqrt(U.dist2(x1, y1, x2, y2));
|
||||
|
||||
// 角度归一化到 [-PI, PI]
|
||||
U.normAngle = a => {
|
||||
while (a > Math.PI) a -= Math.PI * 2;
|
||||
while (a < -Math.PI) a += Math.PI * 2;
|
||||
return a;
|
||||
};
|
||||
|
||||
// 数字格式化:>=1e6 显示为 "1,194k",否则千分位
|
||||
U.fmt = n => {
|
||||
n = Math.floor(n);
|
||||
if (n >= 1e6) return Math.floor(n / 1000).toLocaleString('en-US') + 'k';
|
||||
return n.toLocaleString('en-US');
|
||||
};
|
||||
|
||||
// 北京时间日期串 YYYY-MM-DD(今日最佳用)
|
||||
U.todayBJ = () => {
|
||||
const d = new Date(Date.now() + (8 * 60 + new Date().getTimezoneOffset()) * 60000);
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return d.getFullYear() + '-' + m + '-' + day;
|
||||
};
|
||||
|
||||
U.nowStr = () => {
|
||||
const d = new Date();
|
||||
const p = x => String(x).padStart(2, '0');
|
||||
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' +
|
||||
p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
|
||||
};
|
||||
|
||||
// 简单空间哈希网格(金币拾取/碰撞加速)
|
||||
class Grid {
|
||||
constructor(cell) { this.cell = cell; this.m = new Map(); }
|
||||
clear() { this.m.clear(); }
|
||||
_key(cx, cy) { return cx + ',' + cy; }
|
||||
insert(obj) {
|
||||
const cx = Math.floor(obj.x / this.cell), cy = Math.floor(obj.y / this.cell);
|
||||
const k = this._key(cx, cy);
|
||||
let arr = this.m.get(k);
|
||||
if (!arr) { arr = []; this.m.set(k, arr); }
|
||||
arr.push(obj);
|
||||
}
|
||||
query(x, y, r) {
|
||||
const out = [];
|
||||
const c0 = Math.floor((x - r) / this.cell), c1 = Math.floor((x + r) / this.cell);
|
||||
const r0 = Math.floor((y - r) / this.cell), r1 = Math.floor((y + r) / this.cell);
|
||||
for (let cx = c0; cx <= c1; cx++) for (let cy = r0; cy <= r1; cy++) {
|
||||
const arr = this.m.get(this._key(cx, cy));
|
||||
if (arr) for (const o of arr) out.push(o);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
U.Grid = Grid;
|
||||
})();
|
||||
@@ -0,0 +1,146 @@
|
||||
/* 贪蛇出动 - 世界:金币生成/拾取/磁铁/边界/相机/死亡掉币 */
|
||||
(function () {
|
||||
'use strict';
|
||||
window.GS = window.GS || {};
|
||||
const U = GS.U;
|
||||
|
||||
class World {
|
||||
constructor() {
|
||||
this.size = GS.CFG.worldSize;
|
||||
this.coins = []; // {x,y,r,value,kind:0普通 1星 2大, phase}
|
||||
this.grid = new U.Grid(220);
|
||||
this.cam = { x: this.size / 2, y: this.size / 2, scale: 1 };
|
||||
this.seed();
|
||||
}
|
||||
|
||||
seed() {
|
||||
this.coins.length = 0;
|
||||
for (let i = 0; i < GS.CFG.coinCount; i++) this.coins.push(this._makeCoin());
|
||||
this._rebuild();
|
||||
}
|
||||
|
||||
_makeCoin(x, y, kind) {
|
||||
const k = kind != null ? kind : (Math.random() < 0.08 ? 1 : 0);
|
||||
const base = {
|
||||
0: { r: 7, value: 2 },
|
||||
1: { r: 12, value: 10 }, // 星币:+幸运星
|
||||
2: { r: 14, value: 20 } // 大金币:尸体掉落
|
||||
}[k];
|
||||
return {
|
||||
x: x != null ? x : U.rand(60, this.size - 60),
|
||||
y: y != null ? y : U.rand(60, this.size - 60),
|
||||
r: base.r, value: base.value, kind: k,
|
||||
phase: U.rand(0, Math.PI * 2),
|
||||
vx: 0, vy: 0
|
||||
};
|
||||
}
|
||||
|
||||
_rebuild() {
|
||||
this.grid.clear();
|
||||
for (const c of this.coins) this.grid.insert(c);
|
||||
}
|
||||
|
||||
// 尸体掉币:沿身体间隔掉落
|
||||
dropFromSegments(segs) {
|
||||
for (let i = 0; i < segs.length; i += 2) {
|
||||
const s = segs[i];
|
||||
this.coins.push(this._makeCoin(
|
||||
U.clamp(s.x + U.rand(-14, 14), 40, this.size - 40),
|
||||
U.clamp(s.y + U.rand(-14, 14), 40, this.size - 40), 2));
|
||||
}
|
||||
this._rebuild();
|
||||
}
|
||||
|
||||
// 外部新增单枚金币(加速掉落等),并重建网格索引
|
||||
addCoin(x, y, kind) {
|
||||
const c = this._makeCoin(
|
||||
U.clamp(x, 40, this.size - 40),
|
||||
U.clamp(y, 40, this.size - 40), kind || 0);
|
||||
c.r = kind === 2 ? 14 : (kind === 1 ? 12 : 5);
|
||||
c.value = kind === 2 ? 20 : (kind === 1 ? 10 : 1);
|
||||
this.coins.push(c);
|
||||
this.grid.insert(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
// 宝藏屋奖励:玩家附近撒一大簇金币
|
||||
treasureBurst(x, y) {
|
||||
for (let i = 0; i < 70; i++) {
|
||||
const a = U.rand(0, Math.PI * 2), d = U.rand(30, 320);
|
||||
this.coins.push(this._makeCoin(
|
||||
U.clamp(x + Math.cos(a) * d, 40, this.size - 40),
|
||||
U.clamp(y + Math.sin(a) * d, 40, this.size - 40),
|
||||
Math.random() < 0.25 ? 1 : (Math.random() < 0.4 ? 2 : 0)));
|
||||
}
|
||||
this._rebuild();
|
||||
}
|
||||
|
||||
update(dt, snakes, now) {
|
||||
let moved = false;
|
||||
for (const s of snakes) {
|
||||
if (s.dead) continue;
|
||||
const h = s.head;
|
||||
const magnet = now < s.magnetUntil;
|
||||
const eatR = s.radius + 16;
|
||||
const pullR = magnet ? 260 : eatR;
|
||||
const near = this.grid.query(h.x, h.y, Math.max(pullR, 60));
|
||||
for (const c of near) {
|
||||
if (c._eaten) continue;
|
||||
const d2 = U.dist2(h.x, h.y, c.x, c.y);
|
||||
if (magnet && d2 < pullR * pullR) {
|
||||
const d = Math.sqrt(d2) || 1;
|
||||
const pull = 420 * dt;
|
||||
c.x += (h.x - c.x) / d * pull;
|
||||
c.y += (h.y - c.y) / d * pull;
|
||||
moved = true;
|
||||
}
|
||||
if (d2 < (eatR + c.r) * (eatR + c.r)) {
|
||||
c._eaten = true;
|
||||
s.grow(c.value);
|
||||
s.coins += c.value;
|
||||
if (c.kind === 1) s.lucky += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.coins.some(c => c._eaten)) {
|
||||
this.coins = this.coins.filter(c => !c._eaten);
|
||||
// 补充普通金币保持总量
|
||||
while (this.coins.length < GS.CFG.coinCount) this.coins.push(this._makeCoin());
|
||||
moved = true;
|
||||
}
|
||||
if (moved) this._rebuild();
|
||||
}
|
||||
|
||||
// 相机跟随
|
||||
updateCam(target, viewW, viewH, dt) {
|
||||
const k = 1 - Math.pow(0.001, dt); // 平滑跟随
|
||||
this.cam.x += (target.x - this.cam.x) * k;
|
||||
this.cam.y += (target.y - this.cam.y) * k;
|
||||
const base = Math.min(viewW, viewH) / 720;
|
||||
this.cam.scale = U.clamp(base, 0.7, 1.6);
|
||||
}
|
||||
|
||||
inBounds(x, y, margin) {
|
||||
return x > margin && y > margin && x < this.size - margin && y < this.size - margin;
|
||||
}
|
||||
|
||||
// 碰撞判定:返回 {type:'body', snake} 或 {type:'wall'} 或 null
|
||||
collide(s, snakes) {
|
||||
const h = s.head;
|
||||
if (!this.inBounds(h.x, h.y, s.radius * 0.5)) return { type: 'wall' };
|
||||
for (const o of snakes) {
|
||||
if (o === s || o.dead) continue;
|
||||
const segs = o.segments();
|
||||
const rr = (s.radius * 0.8 + o.radius * 0.9);
|
||||
// 跳过对方头部附近(允许并排)
|
||||
for (let i = 2; i < segs.length; i++) {
|
||||
const g = segs[i];
|
||||
if (U.dist2(h.x, h.y, g.x, g.y) < rr * rr) return { type: 'body', snake: o };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
GS.World = World;
|
||||
})();
|
||||
@@ -0,0 +1,31 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal
|
||||
rem ==========================================
|
||||
rem oss-browser 一键拉起协议注册脚本
|
||||
rem 用法:把本文件放到与 oss-browser.exe 同一目录,双击运行
|
||||
rem 作用:在当前用户注册表(HKCU)注册 ossbrowser:// 协议,无需管理员权限
|
||||
rem ==========================================
|
||||
set "EXE=%~dp0oss-browser.exe"
|
||||
if not exist "%EXE%" (
|
||||
echo.
|
||||
echo [错误] 未在当前目录找到 oss-browser.exe
|
||||
echo 请把本脚本放入解压后的 oss-browser-win32-x64 文件夹内(与 oss-browser.exe 同级)再运行。
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
reg add "HKCU\Software\Classes\ossbrowser" /ve /d "URL:ossbrowser Protocol" /f >nul
|
||||
reg add "HKCU\Software\Classes\ossbrowser" /v "URL Protocol" /t REG_SZ /d "" /f >nul
|
||||
reg add "HKCU\Software\Classes\ossbrowser\shell\open\command" /ve /d "\"%EXE%\" \"%%1\"" /f >nul
|
||||
if errorlevel 1 (
|
||||
echo [失败] 注册表写入出错。
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
echo [完成] 已注册 ossbrowser:// 协议,指向:
|
||||
echo %EXE%
|
||||
echo 现在可回到网页点击「打开 oss-browser」。首次拉起时浏览器会弹确认框,勾选“始终允许”后即为无感打开。
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"_comment": "OSS中转直链(oss_browser_link)的鉴权配置",
|
||||
"enable_auth_comment": "true:启用 tool 外层鉴权;false:完全放开访问",
|
||||
"enable_auth": true,
|
||||
"max_age_days_comment": "鉴权 cookie 最大有效天数(1~365)",
|
||||
"max_age_days": 30,
|
||||
"cookieName_comment": "鉴权 Cookie 名称,默认格式 <tool>_gate",
|
||||
"cookieName": "oss_browser_link_gate",
|
||||
"requireGuid_comment": "true:强制 GUID 参数才能访问;false:允许仅凭 token 或已有会话访问",
|
||||
"requireGuid": false,
|
||||
"guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid",
|
||||
"guidParam": "guid",
|
||||
"guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换(留空表示不启用 GUID)",
|
||||
"guidValue": ""
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>OSS中转直链</title>
|
||||
<link rel="stylesheet" href="oss_browser_link.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="obl-root">
|
||||
<header id="obl-header">
|
||||
<h1>☁️ OSS中转直链</h1>
|
||||
<p id="obl-sub">oss-browser 安装包 · 鉴权下载 · 一键拉起</p>
|
||||
</header>
|
||||
|
||||
<section id="obl-file-card">
|
||||
<div id="obl-file-icon">📦</div>
|
||||
<div id="obl-file-info">
|
||||
<div id="obl-file-name">oss-browser-win32-x64.zip</div>
|
||||
<div id="obl-file-meta">加载中…</div>
|
||||
</div>
|
||||
<span id="obl-file-status" class="obl-status">检测中</span>
|
||||
</section>
|
||||
|
||||
<section id="obl-link-row">
|
||||
<input id="obl-link-input" readonly value="">
|
||||
<button id="obl-copy-btn" class="obl-btn ghost">复制直链</button>
|
||||
</section>
|
||||
|
||||
<section id="obl-actions">
|
||||
<button id="obl-download-btn" class="obl-btn primary">⬇ 下载 oss-browser 安装包</button>
|
||||
<button id="obl-launch-btn" class="obl-btn green">🚀 打开 oss-browser</button>
|
||||
<button id="obl-reg-btn" class="obl-btn ghost">📄 下载一键注册脚本(首次)</button>
|
||||
</section>
|
||||
|
||||
<section id="obl-steps">
|
||||
<h2>使用说明</h2>
|
||||
<ol>
|
||||
<li><b>下载</b>:点击上方「下载」获取安装包(直链本身受 Toolbox 鉴权保护,未授权无法下载)。</li>
|
||||
<li><b>解压</b>:将 zip 解压到一个固定目录(如 <code>D:\oss-browser</code>)。</li>
|
||||
<li><b>注册协议(仅首次)</b>:下载「一键注册脚本」,放入解压目录(与 oss-browser.exe 同级),双击运行。脚本会在当前用户注册表注册 <code>ossbrowser://</code> 协议,无需管理员权限。</li>
|
||||
<li><b>一键打开</b>:以后点击「打开 oss-browser」即可直接拉起本地程序(浏览器首次会弹确认框,勾选"始终允许"后不再打扰)。</li>
|
||||
<li>若检测到未安装,会自动回退为下载安装包。</li>
|
||||
|
||||
<li>阿里OSS直链中转。DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_ID是LTAI5t9fraTXxm-dafe-TwE31Ph2ne</li>
|
||||
<li>DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_SECRET是LBN3ntJfdwlnmCiA9QCed-dafe-Y7XlhxpvX</li>
|
||||
<li>路径是oss://android-o1-images/trae-dafe-files-temp/</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<div id="obl-toast" class="obl-hidden"></div>
|
||||
|
||||
<div id="obl-dialog" class="obl-hidden">
|
||||
<div id="obl-dialog-box">
|
||||
<div id="obl-dialog-head">
|
||||
<span id="obl-dialog-title">交互详情</span>
|
||||
<button id="obl-dialog-copy">复制</button>
|
||||
<button id="obl-dialog-close">×</button>
|
||||
</div>
|
||||
<pre id="obl-dialog-body"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="oss_browser_link.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,122 @@
|
||||
/* OSS中转直链 oss_browser_link 样式 */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
background: linear-gradient(160deg, #10162e 0%, #1a2450 60%, #10162e 100%);
|
||||
color: #e8ecff;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px 16px calc(24px + env(safe-area-inset-bottom));
|
||||
}
|
||||
#obl-root { width: 100%; max-width: 640px; }
|
||||
|
||||
#obl-header h1 { font-size: 24px; letter-spacing: 1px; }
|
||||
#obl-sub { color: #8f9ac8; font-size: 13px; margin-top: 6px; }
|
||||
|
||||
#obl-file-card {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid rgba(255,255,255,.1);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
#obl-file-icon { font-size: 40px; }
|
||||
#obl-file-info { flex: 1; min-width: 0; }
|
||||
#obl-file-name { font-weight: 600; font-size: 15px; word-break: break-all; }
|
||||
#obl-file-meta { color: #8f9ac8; font-size: 12px; margin-top: 5px; }
|
||||
.obl-status {
|
||||
font-size: 12px; padding: 4px 10px; border-radius: 999px; white-space: nowrap;
|
||||
background: rgba(255,255,255,.1); color: #aab4e0;
|
||||
}
|
||||
.obl-status.ok { background: rgba(46,204,113,.16); color: #4be08a; }
|
||||
.obl-status.miss { background: rgba(255,120,120,.16); color: #ff8f8f; }
|
||||
|
||||
#obl-link-row { display: flex; gap: 8px; margin-top: 14px; }
|
||||
#obl-link-input {
|
||||
flex: 1; min-width: 0;
|
||||
background: rgba(0,0,0,.3);
|
||||
border: 1px solid rgba(255,255,255,.14);
|
||||
border-radius: 10px;
|
||||
color: #b9c3ef;
|
||||
font-size: 12px;
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.obl-btn {
|
||||
border: none; border-radius: 10px; cursor: pointer;
|
||||
font-size: 14px; padding: 12px 16px; color: #fff;
|
||||
transition: opacity .15s, transform .05s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.obl-btn:active { transform: scale(.97); }
|
||||
.obl-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.obl-btn.primary { background: linear-gradient(135deg, #3d7bff, #5a4dff); }
|
||||
.obl-btn.green { background: linear-gradient(135deg, #20b26a, #17a05e); }
|
||||
.obl-btn.ghost {
|
||||
background: transparent; color: #aab4e0;
|
||||
border: 1px solid rgba(255,255,255,.22);
|
||||
}
|
||||
|
||||
#obl-actions { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; }
|
||||
#obl-link-row .obl-btn { white-space: nowrap; }
|
||||
|
||||
#obl-steps {
|
||||
margin-top: 22px;
|
||||
background: rgba(255,255,255,.04);
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
#obl-steps h2 { font-size: 15px; margin-bottom: 10px; color: #cfd7ff; }
|
||||
#obl-steps ol { padding-left: 20px; }
|
||||
#obl-steps li { font-size: 13px; color: #9aa5d2; line-height: 1.9; }
|
||||
#obl-steps code {
|
||||
background: rgba(0,0,0,.35); padding: 1px 6px; border-radius: 5px;
|
||||
font-size: 12px; color: #7fd0ff;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
#obl-toast {
|
||||
position: fixed; left: 50%; top: 18%; transform: translateX(-50%);
|
||||
background: #2d3a6e; color: #fff; font-size: 14px;
|
||||
padding: 10px 18px; border-radius: 10px; z-index: 60;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,.4);
|
||||
max-width: 86vw;
|
||||
}
|
||||
#obl-toast.success { background: #1d9e58; }
|
||||
#obl-toast.error { background: #d84a4a; }
|
||||
|
||||
/* 交互详情弹窗(双模式) */
|
||||
#obl-dialog {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.55);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100; padding: 20px;
|
||||
}
|
||||
#obl-dialog-box {
|
||||
width: 100%; max-width: 560px; max-height: 78vh;
|
||||
background: #182047; border: 1px solid rgba(255,255,255,.14);
|
||||
border-radius: 14px; display: flex; flex-direction: column;
|
||||
}
|
||||
#obl-dialog-head {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,.1);
|
||||
}
|
||||
#obl-dialog-title { flex: 1; font-size: 14px; font-weight: 600; }
|
||||
#obl-dialog-head button {
|
||||
background: rgba(255,255,255,.1); border: none; color: #dfe5ff;
|
||||
border-radius: 8px; padding: 6px 12px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
#obl-dialog-body {
|
||||
padding: 12px 14px; overflow: auto; flex: 1;
|
||||
font-size: 12px; line-height: 1.7; color: #b9c3ef; white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
.obl-hidden { display: none !important; }
|
||||
|
||||
@media (min-width: 720px) {
|
||||
#obl-actions { flex-direction: row; flex-wrap: wrap; }
|
||||
#obl-actions .obl-btn { flex: 1; }
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/* OSS中转直链 oss_browser_link 前端逻辑(双模式弹窗 / 交互详情 / 协议拉起) */
|
||||
;(function () {
|
||||
'use strict'
|
||||
var CFG = { ui_mode: 'debug', public_url: '', launch_protocol: 'ossbrowser' }
|
||||
|
||||
function $ (id) { return document.getElementById(id) }
|
||||
|
||||
function bjNow () {
|
||||
try { return new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' }) } catch (e) { return new Date().toLocaleString() }
|
||||
}
|
||||
|
||||
/* ---------- Toast ---------- */
|
||||
var toastTimer = null
|
||||
function showToast (msg, kind) {
|
||||
var el = $('obl-toast')
|
||||
el.textContent = msg
|
||||
el.className = kind || ''
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(function () { el.className = 'obl-hidden' }, 2000)
|
||||
}
|
||||
|
||||
/* ---------- 交互详情弹窗(双模式) ---------- */
|
||||
var dialogTimer = null
|
||||
function showDialog (title, text, opts) {
|
||||
opts = opts || {}
|
||||
$('obl-dialog-title').textContent = title
|
||||
$('obl-dialog-body').textContent = text
|
||||
$('obl-dialog').classList.remove('obl-hidden')
|
||||
clearTimeout(dialogTimer)
|
||||
if (opts.error) return // 异常:停留窗口,不自动关闭
|
||||
var delay = CFG.ui_mode === 'debug' ? 3000 : 2000
|
||||
dialogTimer = setTimeout(closeDialog, delay)
|
||||
}
|
||||
function closeDialog () {
|
||||
clearTimeout(dialogTimer)
|
||||
$('obl-dialog').classList.add('obl-hidden')
|
||||
}
|
||||
|
||||
function buildLog (rec) {
|
||||
return [
|
||||
'触发动作: ' + rec.action,
|
||||
'时间: ' + rec.time,
|
||||
'接口: ' + rec.method + ' ' + rec.url,
|
||||
'请求参数: ' + JSON.stringify(rec.params || {}),
|
||||
'状态: ' + rec.status,
|
||||
'返回结果: ' + rec.result,
|
||||
rec.error ? '错误信息: ' + rec.error : '',
|
||||
'耗时: ' + rec.cost + 'ms'
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
/* 包装一次与后台/系统交互的操作,按 ui_mode 分支提示 */
|
||||
function runTracked (action, url, method, params, fn, opts) {
|
||||
opts = opts || {}
|
||||
var t0 = Date.now()
|
||||
var rec = { action: action, time: bjNow(), url: url, method: method, params: params, status: '进行中', result: '', error: '', cost: 0 }
|
||||
return Promise.resolve()
|
||||
.then(fn)
|
||||
.then(function (result) {
|
||||
rec.cost = Date.now() - t0
|
||||
rec.status = '成功'
|
||||
rec.result = typeof result === 'string' ? result : JSON.stringify(result)
|
||||
if (CFG.ui_mode === 'debug') showDialog('交互详情 · ' + action, buildLog(rec))
|
||||
else if (!opts.silent) showToast(action + ' 成功', 'success')
|
||||
return result
|
||||
})
|
||||
.catch(function (err) {
|
||||
rec.cost = Date.now() - t0
|
||||
rec.status = '失败'
|
||||
rec.error = String((err && err.message) || err)
|
||||
showDialog('交互详情 · ' + action, buildLog(rec), { error: true }) // 异常双模式均强提示
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 初始化:拉配置 + 文件状态 ---------- */
|
||||
function init () {
|
||||
fetch('/api/oss_browser_link/config').then(function (r) { return r.json() }).then(function (j) {
|
||||
if (j && j.ok && j.data) {
|
||||
CFG.ui_mode = j.data.ui_mode || 'debug'
|
||||
CFG.public_url = j.data.public_url || ''
|
||||
CFG.launch_protocol = j.data.launch_protocol || 'ossbrowser'
|
||||
}
|
||||
}).catch(function () {})
|
||||
$('obl-link-input').value = CFG.public_url || location.origin + '/download_files/oss-browser-win32-x64.zip'
|
||||
refreshFileInfo()
|
||||
bindEvents()
|
||||
}
|
||||
|
||||
function fmtSize (n) {
|
||||
if (!Number.isFinite(n)) return ''
|
||||
if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(2) + ' MB'
|
||||
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB'
|
||||
return n + ' B'
|
||||
}
|
||||
|
||||
function refreshFileInfo () {
|
||||
var t0 = Date.now()
|
||||
fetch('/api/oss_browser_link/info').then(function (r) { return r.json() }).then(function (j) {
|
||||
var meta = $('obl-file-meta')
|
||||
var st = $('obl-file-status')
|
||||
var cost = Date.now() - t0
|
||||
if (j && j.ok && j.data && j.data.available) {
|
||||
meta.textContent = '大小 ' + fmtSize(j.data.size) + ' · 已中转至本站 · 鉴权保护'
|
||||
st.textContent = '可下载'
|
||||
st.className = 'obl-status ok'
|
||||
$('obl-download-btn').disabled = false
|
||||
} else {
|
||||
meta.textContent = '尚未检测到中转文件 · 请等待管理员中转后刷新'
|
||||
st.textContent = '未就绪'
|
||||
st.className = 'obl-status miss'
|
||||
$('obl-download-btn').disabled = true
|
||||
}
|
||||
if (CFG.ui_mode === 'debug') {
|
||||
meta.textContent += ' · 检测耗时 ' + cost + 'ms'
|
||||
}
|
||||
}).catch(function () {
|
||||
$('obl-file-meta').textContent = '文件状态检测失败'
|
||||
$('obl-file-status').textContent = '异常'
|
||||
$('obl-file-status').className = 'obl-status miss'
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 按钮事件 ---------- */
|
||||
function bindEvents () {
|
||||
$('obl-copy-btn').addEventListener('click', function () {
|
||||
var url = $('obl-link-input').value
|
||||
var btn = this
|
||||
btn.disabled = true; btn.textContent = '复制中…'
|
||||
runTracked('复制直链', location.href, 'CLIPBOARD', { url: url }, function () {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(url)
|
||||
$('obl-link-input').select()
|
||||
document.execCommand('copy')
|
||||
return 'fallback copy ok'
|
||||
}).then(function () {
|
||||
showToast('直链已复制(对方需鉴权才能下载)', 'success')
|
||||
}).catch(function () {})
|
||||
btn.disabled = false; btn.textContent = '复制直链'
|
||||
})
|
||||
|
||||
$('obl-download-btn').addEventListener('click', function () {
|
||||
var btn = this
|
||||
btn.disabled = true; btn.textContent = '准备下载…'
|
||||
runTracked('下载安装包', '/download_files/oss-browser-win32-x64.zip', 'GET', {}, function () {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var xhr = new XMLHttpRequest()
|
||||
xhr.open('GET', '/download_files/oss-browser-win32-x64.zip')
|
||||
xhr.responseType = 'blob'
|
||||
xhr.onload = function () {
|
||||
if (xhr.status !== 200) return reject(new Error('HTTP ' + xhr.status))
|
||||
var a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(xhr.response)
|
||||
a.download = 'oss-browser-win32-x64.zip'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); a.remove() }, 4000)
|
||||
resolve('下载已触发,大小 ' + (xhr.response ? xhr.response.size : 0) + ' 字节')
|
||||
}
|
||||
xhr.onerror = function () { reject(new Error('网络错误')) }
|
||||
xhr.send()
|
||||
})
|
||||
}).catch(function () {})
|
||||
btn.disabled = false; btn.textContent = '⬇ 下载 oss-browser 安装包'
|
||||
})
|
||||
|
||||
$('obl-launch-btn').addEventListener('click', function () {
|
||||
launchOssBrowser()
|
||||
})
|
||||
|
||||
$('obl-reg-btn').addEventListener('click', function () {
|
||||
var btn = this
|
||||
btn.disabled = true; btn.textContent = '下载中…'
|
||||
runTracked('下载注册脚本', '/tools/oss_browser_link/assets/register_ossbrowser_launch.bat', 'GET', {}, function () {
|
||||
var a = document.createElement('a')
|
||||
a.href = '/tools/oss_browser_link/assets/register_ossbrowser_launch.bat'
|
||||
a.download = 'register_ossbrowser_launch.bat'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(function () { a.remove() }, 2000)
|
||||
return '脚本下载已触发(放入解压目录后双击运行)'
|
||||
}).catch(function () {})
|
||||
btn.disabled = false; btn.textContent = '📄 下载一键注册脚本(首次)'
|
||||
})
|
||||
|
||||
$('obl-dialog-close').addEventListener('click', closeDialog)
|
||||
$('obl-dialog-copy').addEventListener('click', function () {
|
||||
var text = $('obl-dialog-body').textContent
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text)
|
||||
showToast('交互日志已复制', 'success')
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 协议拉起:未装则回退下载 ---------- */
|
||||
function launchOssBrowser () {
|
||||
var proto = CFG.launch_protocol || 'ossbrowser'
|
||||
var url = proto + '://launch?t=' + Date.now()
|
||||
var btn = $('obl-launch-btn')
|
||||
btn.disabled = true; btn.textContent = '正在打开…'
|
||||
runTracked('打开 oss-browser', url, 'PROTOCOL', { protocol: proto }, function () {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var done = false
|
||||
var started = false
|
||||
/* 拉起成功时浏览器通常会模糊/隐藏页面 */
|
||||
window.addEventListener('blur', onBlur)
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
function onBlur () { started = true }
|
||||
function onVis () { if (document.hidden) started = true }
|
||||
try {
|
||||
var f = document.createElement('iframe')
|
||||
f.style.display = 'none'
|
||||
f.src = url
|
||||
document.body.appendChild(f)
|
||||
setTimeout(function () { try { f.remove() } catch (e) {} }, 3000)
|
||||
} catch (e) { location.href = url }
|
||||
setTimeout(function () {
|
||||
window.removeEventListener('blur', onBlur)
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
if (done) return
|
||||
done = true
|
||||
if (started) resolve('已触发协议拉起')
|
||||
else reject(new Error('未检测到已安装的 oss-browser(协议未注册)'))
|
||||
}, 2500)
|
||||
})
|
||||
}).then(function () {
|
||||
showToast('已尝试打开 oss-browser;若浏览器弹出确认框请勾选"始终允许"', 'success')
|
||||
}).catch(function () {
|
||||
/* 未安装:自动回退下载安装包 */
|
||||
showToast('未检测到 oss-browser,已自动开始下载安装包', 'error')
|
||||
setTimeout(function () { $('obl-download-btn').click() }, 600)
|
||||
})
|
||||
setTimeout(function () { btn.disabled = false; btn.textContent = '🚀 打开 oss-browser' }, 3200)
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init)
|
||||
else init()
|
||||
})()
|
||||
@@ -640,21 +640,35 @@ body {
|
||||
|
||||
.pc-image-thumb {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(63, 70, 89, 0.4);
|
||||
background: rgba(18, 20, 28, 0.7);
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pc-image-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 180px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pc-image-thumb-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid rgba(63, 70, 89, 0.35);
|
||||
background: rgba(14, 16, 22, 0.7);
|
||||
}
|
||||
|
||||
.pc-image-thumb-actions .btn {
|
||||
font-size: 12px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.pc-image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
|
||||
@@ -171,6 +171,37 @@ const copyImageToClipboard = async (url) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 限时直通URL:生成 3 天有效的无鉴权直链并复制(按钮带 loading 防重复点击)
|
||||
const copyShareLink = async (itemId, btn) => {
|
||||
if (!itemId) return
|
||||
const originText = btn ? btn.textContent : ''
|
||||
try {
|
||||
if (btn) {
|
||||
btn.disabled = true
|
||||
btn.textContent = '生成中…'
|
||||
}
|
||||
const data = await apiJson(`${apiBase}/share/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: itemId })
|
||||
})
|
||||
if (!data || !data.url) throw new Error('empty_share_url')
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.url)
|
||||
} catch (e) {
|
||||
window.prompt('自动复制失败,请手动复制以下直链:', data.url)
|
||||
}
|
||||
setStatus(`已复制限时直通URL(有效期至 ${data.expires_at_text || '3天后'},过期后重新点击按钮生成)`, 'ok')
|
||||
} catch (e) {
|
||||
setStatus(`生成限时直通URL失败: ${String(e.message || e)}`, 'error')
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false
|
||||
btn.textContent = originText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const detectMobile = () => {
|
||||
if (window.matchMedia) {
|
||||
return window.matchMedia('(max-width: 900px), (pointer: coarse)').matches
|
||||
@@ -580,7 +611,7 @@ const buildItemCard = (item) => {
|
||||
if (images.length) {
|
||||
const grid = document.createElement('div')
|
||||
grid.className = 'pc-image-grid'
|
||||
const viewImages = images.map(i => ({ url: i.file_url, name: i.file_name || '图片' }))
|
||||
const viewImages = images.map(i => ({ url: i.file_url, name: i.file_name || '图片', id: i.id }))
|
||||
images.forEach((imgItem, idx) => {
|
||||
const wrap = document.createElement('div')
|
||||
wrap.className = 'pc-image-thumb'
|
||||
@@ -588,6 +619,19 @@ const buildItemCard = (item) => {
|
||||
img.src = imgItem.file_url
|
||||
img.alt = imgItem.file_name || 'image'
|
||||
wrap.appendChild(img)
|
||||
const thumbActions = document.createElement('div')
|
||||
thumbActions.className = 'pc-image-thumb-actions'
|
||||
const shareBtn = document.createElement('button')
|
||||
shareBtn.className = 'btn'
|
||||
shareBtn.type = 'button'
|
||||
shareBtn.textContent = '限时直通URL'
|
||||
shareBtn.title = '生成3天有效的无鉴权直链,可在第三方浏览器/程序直接打开'
|
||||
shareBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
copyShareLink(imgItem.id, shareBtn)
|
||||
})
|
||||
thumbActions.appendChild(shareBtn)
|
||||
wrap.appendChild(thumbActions)
|
||||
wrap.addEventListener('click', () => openViewerAt(viewImages, idx))
|
||||
grid.appendChild(wrap)
|
||||
})
|
||||
@@ -630,10 +674,16 @@ const buildItemCard = (item) => {
|
||||
setStatus(`下载失败: ${String(e.message || e)}`, 'error')
|
||||
}
|
||||
})
|
||||
const shareFileBtn = document.createElement('button')
|
||||
shareFileBtn.className = 'btn'
|
||||
shareFileBtn.textContent = '限时直通URL'
|
||||
shareFileBtn.title = '生成3天有效的无鉴权直链,可在第三方浏览器/程序直接打开'
|
||||
shareFileBtn.addEventListener('click', () => copyShareLink(fileItem.id, shareFileBtn))
|
||||
row.appendChild(name)
|
||||
row.appendChild(metaText)
|
||||
row.appendChild(copyUrlBtn)
|
||||
row.appendChild(downloadBtn)
|
||||
row.appendChild(shareFileBtn)
|
||||
card.appendChild(row)
|
||||
})
|
||||
}
|
||||
@@ -1366,6 +1416,14 @@ const initApp = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
const viewerShareUrl = $('pc-viewer-share-url')
|
||||
if (viewerShareUrl) {
|
||||
viewerShareUrl.addEventListener('click', () => {
|
||||
const current = state.viewer.images[state.viewer.index]
|
||||
if (current && current.id) copyShareLink(current.id, viewerShareUrl)
|
||||
})
|
||||
}
|
||||
|
||||
const viewerCopyImg = $('pc-viewer-copy-img')
|
||||
if (viewerCopyImg) {
|
||||
viewerCopyImg.addEventListener('click', () => {
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
<button id="pc-viewer-next" class="pc-viewer-nav next" type="button">›</button>
|
||||
<div class="pc-viewer-actions">
|
||||
<button id="pc-viewer-copy-url" class="btn" type="button">复制URL</button>
|
||||
<button id="pc-viewer-share-url" class="btn" type="button" title="生成3天有效的无鉴权直链">限时直通URL</button>
|
||||
<button id="pc-viewer-copy-img" class="btn" type="button">复制图片</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user