feat: 新增多项功能并添加新工具
- 私人剪贴板新增限时直通URL生成功能 - doc_cloud_keeper新增标题复制按钮 - 新增贪蛇出动游戏工具 - 新增OSS中转直链工具 - 添加批量抽帧测试脚本
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 私人剪贴板「限时直通URL」端到端黑盒验证
|
||||
* 流程:/go 拿 gate → 登录 → 上传测试图片 → 创建直链 → 无鉴权访问 → 模拟过期 → 删除清理验证
|
||||
* 用法:node dev_test_scripts/debug/debug_private_clipboard_share_link.js
|
||||
*/
|
||||
const http = require('http')
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const BASE = { hostname: 'localhost', port: 8976 }
|
||||
|
||||
// 1x1 透明 PNG
|
||||
const PNG_1PX = Buffer.from('89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63fcffff3f0300050201aad0a95e0000000049454e44ae426082', 'hex')
|
||||
|
||||
const loadEnv = () => {
|
||||
const out = {}
|
||||
try {
|
||||
const text = fs.readFileSync(path.join(os.homedir(), 'Toolbox_local_creds.env.local'), 'utf8')
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const t = line.trim()
|
||||
if (!t || t.startsWith('#')) continue
|
||||
const eq = t.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim()
|
||||
}
|
||||
} catch {}
|
||||
return out
|
||||
}
|
||||
|
||||
const req = (urlPath, opts = {}) => new Promise((resolve, reject) => {
|
||||
const headers = Object.assign({}, opts.headers || {})
|
||||
if (opts.cookie) headers.Cookie = opts.cookie
|
||||
if (opts.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json'
|
||||
if (opts.body) headers['Content-Length'] = Buffer.byteLength(opts.body)
|
||||
const r = http.request(Object.assign({}, BASE, { path: urlPath, method: opts.method || 'GET', headers }), (rp) => {
|
||||
const chunks = []
|
||||
rp.on('data', c => chunks.push(c))
|
||||
rp.on('end', () => resolve({ status: rp.statusCode, headers: rp.headers, body: Buffer.concat(chunks) }))
|
||||
})
|
||||
r.on('error', reject)
|
||||
if (opts.body) r.write(opts.body)
|
||||
r.end()
|
||||
})
|
||||
|
||||
const mergeCookies = (prev, resp) => {
|
||||
const set = resp.headers['set-cookie'] || []
|
||||
const map = {}
|
||||
String(prev || '').split(/;\s*/).filter(Boolean).forEach(kv => { const i = kv.indexOf('='); if (i > 0) map[kv.slice(0, i)] = kv.slice(i + 1) })
|
||||
set.forEach(c => { const kv = c.split(';')[0]; const i = kv.indexOf('='); if (i > 0) map[kv.slice(0, i)] = kv.slice(i + 1) })
|
||||
return Object.keys(map).map(k => `${k}=${map[k]}`).join('; ')
|
||||
}
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
const check = (name, cond, detail) => {
|
||||
if (cond) { passed++; console.log(` [PASS] ${name}`) } else { failed++; console.log(` [FAIL] ${name} ${detail || ''}`) }
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
const env = loadEnv()
|
||||
const user = env.PRIVATE_CLIPBOARD_USERNAME || ''
|
||||
const pass = env.PRIVATE_CLIPBOARD_PASSWORD || ''
|
||||
if (!user || !pass) { console.error('[FAIL] env 缺少 PRIVATE_CLIPBOARD_USERNAME / PASSWORD'); process.exit(1) }
|
||||
|
||||
// 1. /go 拿 gate cookie
|
||||
let ck = ''
|
||||
let r = await req('/go?systemId=Tools-private_clipboard')
|
||||
while (r.status >= 300 && r.status < 400 && r.headers.location) {
|
||||
ck = mergeCookies(ck, r)
|
||||
r = await req(r.headers.location, { cookie: ck })
|
||||
}
|
||||
ck = mergeCookies(ck, r)
|
||||
check('1 /go gate 链路', /private_clipboard_gate=1/.test(ck), `cookie=${ck}`)
|
||||
|
||||
// 2. 登录拿业务 cookie
|
||||
const login = await req('/api/private_clipboard/auth/login', { method: 'POST', cookie: ck, body: JSON.stringify({ username: user, password: pass }) })
|
||||
ck = mergeCookies(ck, login)
|
||||
const loginJson = JSON.parse(login.body.toString('utf8') || '{}')
|
||||
check('2 业务登录', login.status === 200 && loginJson.ok === true, `status=${login.status}`)
|
||||
|
||||
// 3. 上传测试图片
|
||||
const up = await req('/api/private_clipboard/upload?filename=share_test.png&mime=image/png', { method: 'POST', cookie: ck, body: PNG_1PX, headers: { 'Content-Type': 'image/png' } })
|
||||
const upJson = JSON.parse(up.body.toString('utf8') || '{}')
|
||||
check('3 上传测试图片', up.status === 200 && upJson.ok === true && upJson.id, `status=${up.status} body=${up.body.toString().slice(0, 100)}`)
|
||||
const itemId = upJson.id
|
||||
|
||||
// 4. 创建限时直链
|
||||
const sh = await req('/api/private_clipboard/share/create', { method: 'POST', cookie: ck, body: JSON.stringify({ id: itemId }) })
|
||||
const shJson = JSON.parse(sh.body.toString('utf8') || '{}')
|
||||
check('4 创建限时直链', sh.status === 200 && shJson.ok === true && /^https:\/\/traesite\.umersoft\.com:8975\/share\/clip\/[a-f0-9]{32}$/.test(String(shJson.url || '')), `body=${sh.body.toString().slice(0, 160)}`)
|
||||
const token = String(shJson.url || '').split('/').pop()
|
||||
console.log(` 直链过期时间(北京时间): ${shJson.expires_at_text}`)
|
||||
|
||||
// 5. 无鉴权访问(模拟第三方浏览器,不带任何 cookie)
|
||||
const pub = await req(`/share/clip/${token}`)
|
||||
check('5 无鉴权访问直链', pub.status === 200 && String(pub.headers['content-type'] || '').includes('image/png'), `status=${pub.status} type=${pub.headers['content-type']}`)
|
||||
|
||||
// 5b. 未登录调用 share/create 必须被拒
|
||||
const shNoAuth = await req('/api/private_clipboard/share/create', { method: 'POST', body: JSON.stringify({ id: itemId }) })
|
||||
check('5b 未授权创建直链被拒', shNoAuth.status === 401 || shNoAuth.status === 403, `status=${shNoAuth.status}`)
|
||||
|
||||
// 6. 模拟过期:把 expires_at 改成过去
|
||||
const Database = require(path.join(process.cwd(), 'node_modules', 'better-sqlite3'))
|
||||
const db = new Database(path.join(process.cwd(), 'data', 'private_clipboard.db'))
|
||||
db.prepare('UPDATE clipboard_share_links SET expires_at = ? WHERE token = ?').run(Date.now() - 1000, token)
|
||||
db.close()
|
||||
const expired = await req(`/share/clip/${token}`)
|
||||
const expiredHtml = expired.body.toString('utf8')
|
||||
check('6 过期链接返回 410 + 提示文案', expired.status === 410 && expiredHtml.includes('你的链接期限已经大于三天') && expiredHtml.includes('无法访问'), `status=${expired.status}`)
|
||||
|
||||
// 7. 重新生成(覆盖旧 token),再删除条目,验证 share 链接被清理
|
||||
const sh2 = await req('/api/private_clipboard/share/create', { method: 'POST', cookie: ck, body: JSON.stringify({ id: itemId }) })
|
||||
const sh2Json = JSON.parse(sh2.body.toString('utf8') || '{}')
|
||||
const token2 = String(sh2Json.url || '').split('/').pop()
|
||||
const del = await req(`/api/private_clipboard/${itemId}`, { method: 'DELETE', cookie: ck })
|
||||
const delJson = JSON.parse(del.body.toString('utf8') || '{}')
|
||||
check('7 删除条目', del.status === 200 && delJson.ok === true, `status=${del.status}`)
|
||||
const afterDel = await req(`/share/clip/${token2}`)
|
||||
check('7b 删除后直链失效', afterDel.status === 410, `status=${afterDel.status}`)
|
||||
const db2 = new Database(path.join(process.cwd(), 'data', 'private_clipboard.db'))
|
||||
const left = db2.prepare('SELECT COUNT(1) AS c FROM clipboard_share_links WHERE item_id = ?').get(itemId)
|
||||
db2.close()
|
||||
check('7c share_links 表已清理', Number(left && left.c) === 0, `remaining=${left && left.c}`)
|
||||
|
||||
// 8. 伪造 token
|
||||
const fake = await req('/share/clip/00000000000000000000000000000000')
|
||||
check('8 伪造 token 返回失效页', fake.status === 410, `status=${fake.status}`)
|
||||
|
||||
console.log(`\n结果: ${passed} 通过 / ${failed} 失败`)
|
||||
process.exit(failed ? 1 : 0)
|
||||
})().catch(e => { console.error('[ERR]', e); process.exit(1) })
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* OSS 中转脚本:把 oss://android-o1-images/trae-files-temp/oss-browser-win32-x64.zip
|
||||
* 下载到本地 public/download_files/,成功后立即删除 OSS 对象。
|
||||
*
|
||||
* 密钥来源:~/Toolbox_local_creds.env.local(DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_ID / SECRET)
|
||||
* 用法:node dev_test_scripts/runners/run_oss_browser_zip_transit.js [--keep-oss]
|
||||
* --keep-oss 仅下载不删除 OSS 对象(调试用,默认下载成功后删除)
|
||||
*/
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
const https = require('https')
|
||||
|
||||
const BUCKET = 'android-o1-images'
|
||||
const ENDPOINT = 'oss-cn-shanghai.aliyuncs.com'
|
||||
const OSS_PREFIX = 'trae-files-temp/'
|
||||
const TARGET_NAME = 'oss-browser-win32-x64.zip'
|
||||
const LOCAL_DIR = path.join(process.cwd(), 'public', 'download_files')
|
||||
const LOCAL_FILE = path.join(LOCAL_DIR, TARGET_NAME)
|
||||
|
||||
const loadEnv = () => {
|
||||
const out = {}
|
||||
try {
|
||||
const p = path.join(os.homedir(), 'Toolbox_local_creds.env.local')
|
||||
const text = fs.readFileSync(p, 'utf8')
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const t = line.trim()
|
||||
if (!t || t.startsWith('#')) continue
|
||||
const eq = t.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim()
|
||||
}
|
||||
} catch {}
|
||||
return out
|
||||
}
|
||||
|
||||
const ENV = loadEnv()
|
||||
const AK = ENV.DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_ID || ''
|
||||
const SK = ENV.DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_SECRET || ''
|
||||
if (!AK || !SK) {
|
||||
console.error('[FAIL] 缺少 DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_ID / SECRET(~/Toolbox_local_creds.env.local)')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const sign = (method, date, resource, contentType = '', canonicalHeaders = '') => {
|
||||
const stringToSign = `${method}\n\n${contentType}\n${date}\n${canonicalHeaders ? canonicalHeaders + '\n' : ''}${resource}`
|
||||
return crypto.createHmac('sha1', SK).update(stringToSign).digest('base64')
|
||||
}
|
||||
|
||||
const ossRequest = (method, urlPath, resource) => new Promise((resolve, reject) => {
|
||||
const date = new Date().toUTCString()
|
||||
const req = https.request({
|
||||
hostname: `${BUCKET}.${ENDPOINT}`,
|
||||
path: urlPath,
|
||||
method,
|
||||
headers: {
|
||||
Host: `${BUCKET}.${ENDPOINT}`,
|
||||
Date: date,
|
||||
Authorization: `OSS ${AK}:${sign(method, date, resource)}`
|
||||
}
|
||||
}, (res) => {
|
||||
const chunks = []
|
||||
res.on('data', c => chunks.push(c))
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }))
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.setTimeout(120000, () => req.destroy(new Error('timeout')))
|
||||
req.end()
|
||||
})
|
||||
|
||||
const listPrefix = async (prefix) => {
|
||||
const resp = await ossRequest('GET', `/?prefix=${encodeURIComponent(prefix)}&max-keys=100`, `/${BUCKET}/`)
|
||||
if (resp.status !== 200) throw new Error(`OSS list 失败: ${resp.status} ${resp.body.toString('utf8').slice(0, 200)}`)
|
||||
const xml = resp.body.toString('utf8')
|
||||
const keys = []
|
||||
let idx = 0
|
||||
while (true) {
|
||||
const s = xml.indexOf('<Key>', idx)
|
||||
if (s < 0) break
|
||||
const e = xml.indexOf('</Key>', s)
|
||||
if (e < 0) break
|
||||
keys.push(xml.slice(s + 5, e))
|
||||
idx = e
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
const downloadToFile = (key, localPath) => new Promise((resolve, reject) => {
|
||||
const date = new Date().toUTCString()
|
||||
const resource = `/${BUCKET}/${key}`
|
||||
const req = https.request({
|
||||
hostname: `${BUCKET}.${ENDPOINT}`,
|
||||
path: `/${key.split('/').map(encodeURIComponent).join('/')}`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Host: `${BUCKET}.${ENDPOINT}`,
|
||||
Date: date,
|
||||
Authorization: `OSS ${AK}:${sign('GET', date, resource)}`
|
||||
}
|
||||
}, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
const chunks = []
|
||||
res.on('data', c => chunks.push(c))
|
||||
res.on('end', () => reject(new Error(`OSS 下载失败: ${res.statusCode} ${Buffer.concat(chunks).toString('utf8').slice(0, 200)}`)))
|
||||
return
|
||||
}
|
||||
const ws = fs.createWriteStream(localPath)
|
||||
let received = 0
|
||||
res.on('data', c => { received += c.length })
|
||||
res.pipe(ws)
|
||||
ws.on('finish', () => resolve(received))
|
||||
ws.on('error', reject)
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.setTimeout(600000, () => req.destroy(new Error('下载超时')))
|
||||
req.end()
|
||||
})
|
||||
|
||||
const deleteObject = async (key) => {
|
||||
const resource = `/${BUCKET}/${key}`
|
||||
const urlPath = `/${key.split('/').map(encodeURIComponent).join('/')}`
|
||||
const resp = await ossRequest('DELETE', urlPath, resource)
|
||||
if (resp.status !== 204 && resp.status !== 200) {
|
||||
throw new Error(`OSS 删除失败: ${resp.status} ${resp.body.toString('utf8').slice(0, 200)}`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const bjNow = () => new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' })
|
||||
|
||||
;(async () => {
|
||||
console.log(`[${bjNow()}] 列举 ${BUCKET}/${OSS_PREFIX} ...`)
|
||||
const keys = await listPrefix(OSS_PREFIX)
|
||||
console.log(` 前缀下共 ${keys.length} 个对象`)
|
||||
const zipKey = keys.find(k => k.endsWith('/' + TARGET_NAME) || k === TARGET_NAME)
|
||||
if (!zipKey) {
|
||||
console.error(`[FAIL] 未找到 ${OSS_PREFIX}${TARGET_NAME},现有对象:`)
|
||||
keys.forEach(k => console.error(' - ' + k))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(` 找到对象: ${zipKey}`)
|
||||
|
||||
if (!fs.existsSync(LOCAL_DIR)) fs.mkdirSync(LOCAL_DIR, { recursive: true })
|
||||
const tmpFile = LOCAL_FILE + '.part'
|
||||
console.log(`[${bjNow()}] 开始下载到 ${LOCAL_FILE} ...`)
|
||||
const received = await downloadToFile(zipKey, tmpFile)
|
||||
const size = fs.statSync(tmpFile).size
|
||||
if (size < 1024 * 1024) {
|
||||
fs.unlinkSync(tmpFile)
|
||||
throw new Error(`下载文件过小(${size} 字节),疑似异常,已丢弃临时文件`)
|
||||
}
|
||||
if (fs.existsSync(LOCAL_FILE)) fs.unlinkSync(LOCAL_FILE)
|
||||
fs.renameSync(tmpFile, LOCAL_FILE)
|
||||
console.log(` 下载完成: ${(size / 1024 / 1024).toFixed(2)} MB`)
|
||||
|
||||
if (process.argv.includes('--keep-oss')) {
|
||||
console.log('[SKIP] --keep-oss:保留 OSS 对象不删除')
|
||||
} else {
|
||||
console.log(`[${bjNow()}] 删除 OSS 对象 ${zipKey} ...`)
|
||||
await deleteObject(zipKey)
|
||||
console.log(' OSS 对象已删除(中转完成)')
|
||||
}
|
||||
console.log(`[${bjNow()}] 中转成功:本地 ${LOCAL_FILE}`)
|
||||
})().catch(e => {
|
||||
console.error('[FAIL]', e.message || e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
tool_extract_snake_frames.py
|
||||
从「贪蛇出动」游戏录屏视频中抽帧,用于分析玩法与抠素材。
|
||||
用法:
|
||||
python tool_extract_snake_frames.py # 默认每 2 秒抽 1 帧
|
||||
python tool_extract_snake_frames.py 0.5 # 每 0.5 秒抽 1 帧
|
||||
python tool_extract_snake_frames.py 0.2 60 70 # 每 0.2 秒抽 1 帧,只抽 60~70 秒区间
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import cv2
|
||||
|
||||
VIDEO = r"D:\Trae_Files\TRAE-Toolbox\temp\a4ef87cf84a3152cb5800eae5e06e6a8.mp4"
|
||||
OUT_DIR = r"D:\Trae_Files\TRAE-Toolbox\temp\snake_frames"
|
||||
|
||||
|
||||
def main():
|
||||
step = float(sys.argv[1]) if len(sys.argv) > 1 else 2.0
|
||||
start = float(sys.argv[2]) if len(sys.argv) > 2 else 0.0
|
||||
end = float(sys.argv[3]) if len(sys.argv) > 3 else 1e18
|
||||
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
cap = cv2.VideoCapture(VIDEO)
|
||||
if not cap.isOpened():
|
||||
print("无法打开视频:", VIDEO)
|
||||
sys.exit(1)
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
print(f"fps={fps:.2f} total_frames={total} duration={total / fps:.1f}s step={step}s")
|
||||
|
||||
t = start
|
||||
saved = 0
|
||||
while t <= end:
|
||||
cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000.0)
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
break
|
||||
name = "t%07d.jpg" % int(round(t * 10)) # 文件名单位:0.1 秒
|
||||
cv2.imwrite(os.path.join(OUT_DIR, name), frame, [cv2.IMWRITE_JPEG_QUALITY, 88])
|
||||
saved += 1
|
||||
t += step
|
||||
cap.release()
|
||||
print(f"saved={saved} -> {OUT_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,86 @@
|
||||
/* 贪蛇出动 - 核心逻辑单元测试(无 DOM,Node 直接运行)
|
||||
* 运行: node dev_test_scripts/unit/test_greedy_snake_core.js
|
||||
*/
|
||||
'use strict';
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
global.window = global; // 让模块的 window.GS 挂到全局
|
||||
const JS_DIR = path.join(__dirname, '..', '..', 'public', 'tools', 'greedy_snake', 'js');
|
||||
for (const f of ['config.js', 'utils.js', 'snake.js', 'ai.js', 'world.js']) {
|
||||
const src = fs.readFileSync(path.join(JS_DIR, f), 'utf-8');
|
||||
new Function(src)();
|
||||
}
|
||||
|
||||
const { U, Snake, AI, World, CFG } = global.GS;
|
||||
let failed = 0;
|
||||
const assert = (cond, msg) => {
|
||||
if (cond) console.log(' [PASS] ' + msg);
|
||||
else { failed++; console.error(' [FAIL] ' + msg); }
|
||||
};
|
||||
|
||||
console.log('== 工具函数 ==');
|
||||
assert(U.fmt(276496) === '276,496', 'fmt 千分位: ' + U.fmt(276496));
|
||||
assert(U.fmt(1194000) === '1,194k', 'fmt k 格式: ' + U.fmt(1194000));
|
||||
assert(Math.abs(U.normAngle(Math.PI * 2)) < 1e-9, 'normAngle 归一化');
|
||||
assert(U.clamp(5, 0, 3) === 3, 'clamp');
|
||||
|
||||
console.log('== 蛇移动与采样 ==');
|
||||
const s = new Snake({ id: 'p', name: 't', isPlayer: true, color: { body: '#f00', dark: '#900' }, x: 2000, y: 2000, angle: 0 });
|
||||
s.targetAngle = 0;
|
||||
for (let i = 0; i < 120; i++) s.update(1 / 60, 0);
|
||||
assert(Math.abs(s.head.x - (2000 + 130 * 2)) < 3, '直线移动距离正确: ' + (s.head.x - 2000).toFixed(1));
|
||||
const segs = s.segments();
|
||||
assert(segs.length === s.segCount, '身体节数=' + segs.length);
|
||||
const d01 = U.dist(segs[0].x, segs[0].y, segs[1].x, segs[1].y);
|
||||
assert(Math.abs(d01 - s.spacing) < 0.5, '节间距=spacing: ' + d01.toFixed(2));
|
||||
|
||||
console.log('== 世界/金币/碰撞 ==');
|
||||
const w = new World();
|
||||
assert(w.coins.length >= CFG.coinCount, '初始金币数: ' + w.coins.length);
|
||||
const before = w.coins.length;
|
||||
w.dropFromSegments(segs);
|
||||
assert(w.coins.length > before, '尸体掉币: +' + (w.coins.length - before));
|
||||
|
||||
const s2 = new Snake({ id: 'p2', name: 't2', isPlayer: true, color: { body: '#f00', dark: '#900' }, x: 2000, y: 3000, angle: 0 });
|
||||
s2.targetAngle = 0;
|
||||
for (let i = 0; i < 120; i++) s2.update(1 / 60, 0); // 先走出一条身体
|
||||
const bot = new Snake({ id: 'b', name: 'bot', color: { body: '#fff', dark: '#ccc' }, x: s2.head.x + 120, y: s2.head.y, score: 20, angle: Math.PI });
|
||||
bot.targetAngle = Math.PI; // 朝玩家身体冲
|
||||
let hit = null;
|
||||
for (let i = 0; i < 60 && !hit; i++) {
|
||||
s2.update(1 / 60, 0); bot.update(1 / 60, 0);
|
||||
hit = w.collide(bot, [s2, bot]);
|
||||
}
|
||||
assert(hit !== null, '撞身体判定: ' + (hit && hit.type));
|
||||
|
||||
console.log('== AI + 综合模拟 600 步 ==');
|
||||
const w2 = new World();
|
||||
const player = new Snake({ id: 'p', name: 'me', isPlayer: true, color: { body: '#f00', dark: '#900' }, x: 2000, y: 2000 });
|
||||
const bots = [];
|
||||
const ais = new Map();
|
||||
for (let i = 0; i < CFG.botCount; i++) {
|
||||
const b = new Snake({ id: 'b' + i, name: 'bot' + i, color: { body: '#fff', dark: '#ccc' }, x: U.rand(500, 3500), y: U.rand(500, 3500), score: U.randInt(10, 200) });
|
||||
bots.push(b); ais.set(b.id, new AI(b));
|
||||
}
|
||||
let deaths = 0, eaten0 = player.score;
|
||||
for (let t = 0; t < 600; t++) {
|
||||
player.targetAngle += 0.02;
|
||||
for (const b of bots) ais.get(b.id).update(1 / 60, w2, [player, ...bots]);
|
||||
for (const x of [player, ...bots]) x.update(1 / 60, t);
|
||||
w2.update(1 / 60, [player, ...bots], t);
|
||||
for (const x of [player, ...bots]) {
|
||||
if (x.dead) continue;
|
||||
const h = w2.collide(x, [player, ...bots]);
|
||||
if (h) {
|
||||
if (x.shield) { x.shield = false; continue; }
|
||||
x.die(); deaths++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert(true, '模拟完成,死亡数=' + deaths + ',玩家分数 ' + eaten0 + ' -> ' + Math.floor(player.score));
|
||||
assert(w2.coins.length > 0, '模拟后金币存在: ' + w2.coins.length);
|
||||
assert(isFinite(player.head.x) && isFinite(player.head.y), '玩家坐标有限');
|
||||
|
||||
console.log(failed === 0 ? '\n全部通过' : `\n${failed} 项失败`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user