1288 lines
50 KiB
HTML
1288 lines
50 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
<title>Slither.io Clone - Poki SDK Demo</title>
|
|
<!-- Poki SDK (async load) -->
|
|
<script src="https://game-cdn.poki.com/scripts/v2/poki-sdk.js" async></script>
|
|
<script src="https://code.playcanvas.com/playcanvas-1.66.0.min.js"></script>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
body {
|
|
overflow: hidden;
|
|
background: #1a1a2e;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
touch-action: none;
|
|
}
|
|
#application {
|
|
width: 100vw;
|
|
height: 100vh;
|
|
display: block;
|
|
}
|
|
#ui {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
pointer-events: none;
|
|
z-index: 100;
|
|
}
|
|
#score {
|
|
position: absolute;
|
|
top: 20px;
|
|
left: 20px;
|
|
color: white;
|
|
font-size: 24px;
|
|
font-weight: bold;
|
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
|
|
}
|
|
#length {
|
|
position: absolute;
|
|
top: 50px;
|
|
left: 20px;
|
|
color: #aaa;
|
|
font-size: 14px;
|
|
}
|
|
#menu {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
text-align: center;
|
|
pointer-events: auto;
|
|
}
|
|
#menu h1 {
|
|
color: #00d4ff;
|
|
font-size: 48px;
|
|
margin-bottom: 20px;
|
|
text-shadow: 0 0 20px rgba(0,212,255,0.5);
|
|
}
|
|
.btn {
|
|
padding: 15px 40px;
|
|
font-size: 20px;
|
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 30px;
|
|
cursor: pointer;
|
|
margin: 10px;
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
}
|
|
.btn:hover {
|
|
transform: scale(1.05);
|
|
box-shadow: 0 10px 30px rgba(102,126,234,0.4);
|
|
}
|
|
#gameOver {
|
|
display: none;
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
text-align: center;
|
|
background: rgba(0,0,0,0.8);
|
|
padding: 40px;
|
|
border-radius: 20px;
|
|
pointer-events: auto;
|
|
}
|
|
#gameOver h2 {
|
|
color: #ff6b6b;
|
|
font-size: 36px;
|
|
margin-bottom: 20px;
|
|
}
|
|
#finalScore {
|
|
color: white;
|
|
font-size: 24px;
|
|
margin-bottom: 30px;
|
|
}
|
|
#joystick {
|
|
position: absolute;
|
|
bottom: 100px;
|
|
left: 100px;
|
|
width: 120px;
|
|
height: 120px;
|
|
background: rgba(255,255,255,0.1);
|
|
border: 2px solid rgba(255,255,255,0.3);
|
|
border-radius: 50%;
|
|
display: none;
|
|
pointer-events: auto;
|
|
}
|
|
#joystick-knob {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
width: 50px;
|
|
height: 50px;
|
|
background: rgba(0,212,255,0.6);
|
|
border-radius: 50%;
|
|
transform: translate(-50%, -50%);
|
|
}
|
|
@media (max-width: 768px) {
|
|
#joystick { display: block; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="application" style="display:block;width:100%;height:100%;"></canvas>
|
|
|
|
<div id="ui">
|
|
<div id="score">得分: 0</div>
|
|
<div id="length">长度: 10</div>
|
|
|
|
<div id="menu">
|
|
<h1>🐍 Snake IO</h1>
|
|
<button class="btn" onclick="startGame()">开始游戏</button>
|
|
<button class="btn" onclick="watchAd()">看广告得奖励</button>
|
|
|
|
<!-- 游戏规则 -->
|
|
<div style="margin-top: 30px; padding: 20px; background: rgba(0,0,0,0.6); border-radius: 15px; max-width: 320px; text-align: left;">
|
|
<h3 style="color: #00d4ff; margin-bottom: 15px; text-align: center;">📜 游戏规则</h3>
|
|
<div style="color: #ccc; font-size: 14px; line-height: 1.8;">
|
|
<p style="margin-bottom: 8px;"><span style="color: #4ecdc4;">🎮 控制:</span> 鼠标/手指拖动控制方向</p>
|
|
<p style="margin-bottom: 8px;"><span style="color: #ffeaa7;">🍎 吃食物:</span> 吃掉彩色食物变长得分</p>
|
|
<p style="margin-bottom: 8px;"><span style="color: #ff6b6b;">⚠️ 注意:</span> 撞到其他蛇身体 → 死亡</p>
|
|
<p style="margin-bottom: 8px;"><span style="color: #fd79a8;">💀 头碰头:</span> 双方同时死亡</p>
|
|
<p style="margin-bottom: 0;"><span style="color: #a29bfe;">⭐ 提示:</span> 大蛇被击败后会掉落大量食物!</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="gameOver">
|
|
<h2>游戏结束!</h2>
|
|
<div id="finalScore">最终得分: 0</div>
|
|
<button class="btn" onclick="restartGame()">再玩一次</button>
|
|
</div>
|
|
|
|
<div id="joystick">
|
|
<div id="joystick-knob"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ==================== 等待页面加载完成 ====================
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initGame();
|
|
});
|
|
|
|
// ==================== Poki SDK 集成 ====================
|
|
let pokiReady = false;
|
|
|
|
function initPoki() {
|
|
// 初始化 Poki SDK(包装在 try-catch 中防止出错)
|
|
if (typeof PokiSDK !== 'undefined') {
|
|
return PokiSDK.init().then(() => {
|
|
console.log('✅ Poki SDK 初始化成功');
|
|
pokiReady = true;
|
|
PokiSDK.gameLoadingStart();
|
|
|
|
// 模拟加载完成后通知 Poki
|
|
setTimeout(() => {
|
|
PokiSDK.gameLoadingFinished();
|
|
}, 1000);
|
|
}).catch(err => {
|
|
console.log('⚠️ Poki SDK 初始化失败(非 Poki 环境):', err);
|
|
pokiReady = false;
|
|
});
|
|
} else {
|
|
console.log('⚠️ Poki SDK 未加载(正常,非 Poki 环境)');
|
|
return Promise.resolve();
|
|
}
|
|
}
|
|
|
|
// 广告功能
|
|
window.watchAd = function() {
|
|
if (!pokiReady) {
|
|
alert('广告功能仅在 Poki 平台可用');
|
|
return;
|
|
}
|
|
|
|
PokiSDK.commercialBreak().then(() => {
|
|
console.log('广告结束,给予奖励');
|
|
// 给玩家奖励
|
|
if (snake) {
|
|
snake.grow(5);
|
|
window.updateUI();
|
|
}
|
|
alert('观看完成!获得 +5 长度奖励');
|
|
});
|
|
};
|
|
|
|
// 游戏暂停/恢复(广告用)
|
|
function pauseForAd() {
|
|
if (gameRunning) {
|
|
PokiSDK.gameplayStop();
|
|
}
|
|
}
|
|
|
|
function resumeAfterAd() {
|
|
if (gameRunning) {
|
|
PokiSDK.gameplayStart();
|
|
}
|
|
}
|
|
|
|
// ==================== PlayCanvas 游戏 ====================
|
|
function initGame() {
|
|
console.log('🎮 初始化 PlayCanvas...');
|
|
|
|
const canvas = document.getElementById('application');
|
|
if (!canvas) {
|
|
console.error('❌ Canvas 元素未找到!');
|
|
alert('游戏加载失败:Canvas 未找到');
|
|
return;
|
|
}
|
|
|
|
let app;
|
|
try {
|
|
app = new pc.Application(canvas, {
|
|
mouse: new pc.Mouse(canvas),
|
|
keyboard: new pc.Keyboard(window),
|
|
touch: new pc.TouchDevice(canvas),
|
|
elementInput: new pc.ElementInput(canvas)
|
|
});
|
|
console.log('✅ PlayCanvas 应用创建成功');
|
|
} catch (e) {
|
|
console.error('❌ PlayCanvas 初始化失败:', e);
|
|
alert('游戏引擎初始化失败: ' + e.message);
|
|
throw e;
|
|
}
|
|
|
|
app.start();
|
|
console.log('✅ PlayCanvas 已启动');
|
|
|
|
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
|
|
app.setCanvasResolution(pc.RESOLUTION_AUTO);
|
|
|
|
window.addEventListener('resize', () => app.resizeCanvas());
|
|
|
|
// 游戏状态
|
|
let gameRunning = false;
|
|
let score = 0;
|
|
let playerSnake = null; // 人类玩家
|
|
let aiSnakes = []; // AI玩家数组
|
|
let maxAIPlayers = 8; // 最大AI数量(增加到8个)
|
|
let foods = [];
|
|
let camera = null;
|
|
let maxFoods = 60; // 最大食物数量(增加到60个)
|
|
|
|
// 音频系统 - Web Audio API
|
|
let audioContext = null;
|
|
let soundEnabled = true;
|
|
|
|
function initAudio() {
|
|
if (!audioContext) {
|
|
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
}
|
|
}
|
|
|
|
// 播放音效函数
|
|
function playSound(type) {
|
|
if (!soundEnabled || !audioContext) return;
|
|
|
|
const osc = audioContext.createOscillator();
|
|
const gain = audioContext.createGain();
|
|
|
|
osc.connect(gain);
|
|
gain.connect(audioContext.destination);
|
|
|
|
const now = audioContext.currentTime;
|
|
|
|
switch(type) {
|
|
case 'eat': // 吃食物 - 清脆的"叮"声
|
|
osc.type = 'sine';
|
|
osc.frequency.setValueAtTime(880, now); // A5
|
|
osc.frequency.exponentialRampToValueAtTime(440, now + 0.1);
|
|
gain.gain.setValueAtTime(0.3, now);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.1);
|
|
osc.start(now);
|
|
osc.stop(now + 0.1);
|
|
break;
|
|
|
|
case 'eatBig': // 吃大食物 - 更饱满的音效
|
|
osc.type = 'triangle';
|
|
osc.frequency.setValueAtTime(660, now); // E5
|
|
osc.frequency.exponentialRampToValueAtTime(330, now + 0.2);
|
|
gain.gain.setValueAtTime(0.4, now);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2);
|
|
osc.start(now);
|
|
osc.stop(now + 0.2);
|
|
break;
|
|
|
|
case 'die': // 死亡 - 低沉的"咚"声
|
|
osc.type = 'sawtooth';
|
|
osc.frequency.setValueAtTime(200, now);
|
|
osc.frequency.exponentialRampToValueAtTime(50, now + 0.3);
|
|
gain.gain.setValueAtTime(0.4, now);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.3);
|
|
osc.start(now);
|
|
osc.stop(now + 0.3);
|
|
break;
|
|
|
|
case 'headBump': // 头碰头 - 撞击声
|
|
osc.type = 'square';
|
|
osc.frequency.setValueAtTime(150, now);
|
|
osc.frequency.exponentialRampToValueAtTime(30, now + 0.15);
|
|
gain.gain.setValueAtTime(0.5, now);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
|
|
osc.start(now);
|
|
osc.stop(now + 0.15);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 场景设置
|
|
function setupScene() {
|
|
// 相机
|
|
camera = new pc.Entity('Camera');
|
|
camera.addComponent('camera', {
|
|
clearColor: new pc.Color(0.08, 0.08, 0.12),
|
|
orthographic: true,
|
|
orthoHeight: 12,
|
|
nearClip: 0.1,
|
|
farClip: 100
|
|
});
|
|
camera.setPosition(0, 8, 0);
|
|
camera.setEulerAngles(-90, 0, 0); // 直接向下看
|
|
app.root.addChild(camera);
|
|
console.log('📷 相机创建完成,位置:', camera.getPosition().toString());
|
|
|
|
// 灯光
|
|
const light = new pc.Entity('Light');
|
|
light.addComponent('light', {
|
|
type: 'directional',
|
|
color: new pc.Color(1, 1, 0.95),
|
|
intensity: 1
|
|
});
|
|
light.setEulerAngles(45, 45, 0);
|
|
app.root.addChild(light);
|
|
|
|
// 环境光
|
|
const ambient = new pc.Entity('Ambient');
|
|
ambient.addComponent('light', {
|
|
type: 'point',
|
|
color: new pc.Color(0.3, 0.3, 0.4),
|
|
intensity: 0.5,
|
|
range: 50
|
|
});
|
|
ambient.setPosition(0, 5, 0);
|
|
app.root.addChild(ambient);
|
|
|
|
// 地面
|
|
const ground = new pc.Entity('Ground');
|
|
ground.addComponent('render', { type: 'plane' });
|
|
ground.setLocalScale(40, 1, 40);
|
|
ground.setPosition(0, 0, 0);
|
|
|
|
const groundMat = new pc.StandardMaterial();
|
|
groundMat.diffuse = new pc.Color(0.15, 0.18, 0.22);
|
|
groundMat.update();
|
|
ground.render.material = groundMat;
|
|
app.root.addChild(ground);
|
|
console.log('🟫 地面创建完成');
|
|
|
|
// 网格装饰
|
|
createGrid();
|
|
}
|
|
|
|
function createGrid() {
|
|
const gridSize = 20;
|
|
const gridSpacing = 2;
|
|
|
|
for (let x = -gridSize; x <= gridSize; x += gridSpacing) {
|
|
const line = new pc.Entity();
|
|
line.addComponent('render', { type: 'box' });
|
|
line.setLocalScale(0.02, 0.01, gridSize * 2);
|
|
line.setPosition(x, 0, 0);
|
|
|
|
const mat = new pc.StandardMaterial();
|
|
mat.diffuse = new pc.Color(0.2, 0.25, 0.3);
|
|
mat.update();
|
|
line.render.material = mat;
|
|
app.root.addChild(line);
|
|
}
|
|
|
|
for (let z = -gridSize; z <= gridSize; z += gridSpacing) {
|
|
const line = new pc.Entity();
|
|
line.addComponent('render', { type: 'box' });
|
|
line.setLocalScale(gridSize * 2, 0.01, 0.02);
|
|
line.setPosition(0, 0, z);
|
|
|
|
const mat = new pc.StandardMaterial();
|
|
mat.diffuse = new pc.Color(0.2, 0.25, 0.3);
|
|
mat.update();
|
|
line.render.material = mat;
|
|
app.root.addChild(line);
|
|
}
|
|
}
|
|
|
|
// 蛇类
|
|
class Snake {
|
|
constructor(isAI = false, startPos = null, snakeColor = null) {
|
|
this.segments = [];
|
|
this.history = [];
|
|
this.isAI = isAI;
|
|
this.isAlive = true;
|
|
this.speed = isAI ? 3.2 : 3.5; // 降低初始速度
|
|
this.baseRadius = 0.22; // 基础半径(稍微小一点)
|
|
this.radius = this.baseRadius; // 当前半径(会变胖)
|
|
this.maxRadius = 0.6; // 最大半径限制
|
|
this.segmentDistance = 0.32; // 段间距(稍微紧凑)
|
|
this.totalEaten = 0; // 总共吃了多少食物(用于计算变胖)
|
|
|
|
// 人类玩家用青色,AI用不同颜色(增加更多颜色)
|
|
if (snakeColor) {
|
|
this.color = snakeColor;
|
|
} else if (isAI) {
|
|
// AI颜色:更多颜色选择
|
|
const aiColors = [
|
|
new pc.Color(1, 0.3, 0.3), // 红色
|
|
new pc.Color(1, 0.8, 0.2), // 黄色
|
|
new pc.Color(0.8, 0.3, 1), // 紫色
|
|
new pc.Color(0.3, 0.8, 0.3), // 绿色
|
|
new pc.Color(1, 0.5, 0.2), // 橙色
|
|
new pc.Color(0.3, 0.5, 1), // 蓝色
|
|
new pc.Color(1, 0.3, 0.6), // 粉红
|
|
new pc.Color(0.6, 0.3, 0.1) // 棕色
|
|
];
|
|
this.color = aiColors[Math.floor(Math.random() * aiColors.length)];
|
|
} else {
|
|
this.color = new pc.Color(0, 0.8, 1); // 人类玩家:青色
|
|
}
|
|
this.direction = new pc.Vec3(1, 0, 0);
|
|
this.targetDirection = new pc.Vec3(1, 0, 0);
|
|
this.angle = 0;
|
|
this.invulnerableTime = 2.0;
|
|
this.startPos = startPos || new pc.Vec3(0, this.radius, 0);
|
|
|
|
this.createHead();
|
|
this.createInitialBody(10);
|
|
}
|
|
|
|
createHead() {
|
|
this.head = new pc.Entity(this.isAI ? 'AI_SnakeHead' : 'Player_SnakeHead');
|
|
this.head.addComponent('render', { type: 'sphere' });
|
|
this.head.setLocalScale(this.radius * 2, this.radius * 2, this.radius * 2);
|
|
this.head.setPosition(this.startPos.x, this.startPos.y, this.startPos.z);
|
|
|
|
const mat = new pc.StandardMaterial();
|
|
mat.diffuse = this.color;
|
|
mat.shininess = 80;
|
|
mat.emissive = new pc.Color(this.color.r * 0.5, this.color.g * 0.5, this.color.b * 0.5);
|
|
mat.update();
|
|
this.head.render.material = mat;
|
|
|
|
// 眼睛
|
|
this.createEyes();
|
|
|
|
app.root.addChild(this.head);
|
|
if (!this.isAI) {
|
|
console.log('🎯 玩家蛇头创建完成,位置:', this.head.getPosition().toString());
|
|
}
|
|
}
|
|
|
|
createEyes() {
|
|
const leftEye = new pc.Entity('LeftEye');
|
|
leftEye.addComponent('render', { type: 'sphere' });
|
|
leftEye.setLocalScale(0.1, 0.1, 0.1);
|
|
leftEye.setPosition(-0.15, 0.1, 0.15);
|
|
|
|
const eyeMat = new pc.StandardMaterial();
|
|
eyeMat.diffuse = new pc.Color(1, 1, 1);
|
|
eyeMat.update();
|
|
leftEye.render.material = eyeMat;
|
|
this.head.addChild(leftEye);
|
|
|
|
const rightEye = new pc.Entity('RightEye');
|
|
rightEye.addComponent('render', { type: 'sphere' });
|
|
rightEye.setLocalScale(0.1, 0.1, 0.1);
|
|
rightEye.setPosition(0.15, 0.1, 0.15);
|
|
rightEye.render.material = eyeMat;
|
|
this.head.addChild(rightEye);
|
|
}
|
|
|
|
createInitialBody(count) {
|
|
// 创建初始身体,确保不会与头部重叠
|
|
const headPos = this.head.getPosition();
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const segment = new pc.Entity('Segment' + i);
|
|
segment.addComponent('render', { type: 'sphere' });
|
|
|
|
const scale = this.radius * 1.8 * (1 - i * 0.02);
|
|
segment.setLocalScale(scale, scale, scale);
|
|
|
|
// 渐变色
|
|
const t = i / 50;
|
|
const segColor = new pc.Color(
|
|
this.color.r * (1 - t * 0.5),
|
|
this.color.g * (1 - t * 0.3),
|
|
this.color.b
|
|
);
|
|
|
|
const mat = new pc.StandardMaterial();
|
|
mat.diffuse = segColor;
|
|
mat.shininess = 60;
|
|
mat.update();
|
|
segment.render.material = mat;
|
|
|
|
// 沿 X 轴负方向排开,确保不与头部重叠
|
|
const xOffset = -(i + 1) * this.segmentDistance * 2;
|
|
segment.setPosition(headPos.x + xOffset, this.radius, headPos.z);
|
|
|
|
app.root.addChild(segment);
|
|
this.segments.push(segment);
|
|
}
|
|
|
|
// 预填充历史位置
|
|
this.prepopulateHistory();
|
|
}
|
|
|
|
prepopulateHistory() {
|
|
const headPos = this.head.getPosition();
|
|
// 预填充足够多的历史位置
|
|
const totalNeeded = this.segments.length * 4 + 20;
|
|
for (let i = 0; i < totalNeeded; i++) {
|
|
const pos = headPos.clone();
|
|
// 沿 X 轴负方向延伸,确保身体段跟随头部
|
|
pos.x -= i * (this.segmentDistance / 4);
|
|
this.history.push(pos);
|
|
}
|
|
}
|
|
|
|
grow(count = 1) {
|
|
for (let i = 0; i < count; i++) {
|
|
const segment = new pc.Entity('Segment' + this.segments.length);
|
|
segment.addComponent('render', { type: 'sphere' });
|
|
|
|
const idx = this.segments.length;
|
|
const scale = this.radius * 1.8 * (1 - idx * 0.02);
|
|
segment.setLocalScale(scale, scale, scale);
|
|
|
|
// 渐变色
|
|
const t = idx / 50;
|
|
const segColor = new pc.Color(
|
|
this.color.r * (1 - t * 0.5),
|
|
this.color.g * (1 - t * 0.3),
|
|
this.color.b
|
|
);
|
|
|
|
const mat = new pc.StandardMaterial();
|
|
mat.diffuse = segColor;
|
|
mat.shininess = 60;
|
|
mat.update();
|
|
segment.render.material = mat;
|
|
|
|
// 在最后一个段后面添加
|
|
const lastSeg = this.segments[this.segments.length - 1];
|
|
const lastPos = lastSeg ? lastSeg.getPosition() : this.head.getPosition();
|
|
segment.setPosition(lastPos.x - this.segmentDistance, this.radius, lastPos.z);
|
|
|
|
app.root.addChild(segment);
|
|
this.segments.push(segment);
|
|
}
|
|
}
|
|
|
|
// 更新所有身体段的粗细(变胖效果)
|
|
updateThickness() {
|
|
// 头部
|
|
this.head.setLocalScale(this.radius * 2, this.radius * 2, this.radius * 2);
|
|
|
|
// 身体段
|
|
for (let i = 0; i < this.segments.length; i++) {
|
|
const scale = this.radius * 1.8 * (1 - i * 0.015);
|
|
this.segments[i].setLocalScale(scale, scale, scale);
|
|
// 更新Y位置(贴地)
|
|
const pos = this.segments[i].getPosition();
|
|
pos.y = this.radius;
|
|
this.segments[i].setPosition(pos);
|
|
}
|
|
}
|
|
|
|
// AI寻找最近的食物
|
|
findNearestFood() {
|
|
const headPos = this.head.getPosition();
|
|
let nearest = null;
|
|
let minDist = Infinity;
|
|
|
|
for (let food of foods) {
|
|
const dist = headPos.distance(food.getPosition());
|
|
if (dist < minDist) {
|
|
minDist = dist;
|
|
nearest = food;
|
|
}
|
|
}
|
|
return nearest;
|
|
}
|
|
|
|
// AI更新
|
|
aiUpdate(dt) {
|
|
if (!gameRunning || !this.isAlive) return;
|
|
|
|
// 减少无敌时间
|
|
if (this.invulnerableTime > 0) {
|
|
this.invulnerableTime -= dt;
|
|
}
|
|
|
|
const headPos = this.head.getPosition();
|
|
|
|
// 寻找最近的食物作为目标
|
|
const nearestFood = this.findNearestFood();
|
|
let targetPos = null;
|
|
|
|
if (nearestFood) {
|
|
targetPos = nearestFood.getPosition();
|
|
} else {
|
|
// 没有食物就随机游走
|
|
targetPos = new pc.Vec3(
|
|
headPos.x + this.direction.x * 5,
|
|
0,
|
|
headPos.z + this.direction.z * 5
|
|
);
|
|
}
|
|
|
|
// 简单的边界避让
|
|
if (headPos.x > 17) targetPos.x = headPos.x - 5;
|
|
if (headPos.x < -17) targetPos.x = headPos.x + 5;
|
|
if (headPos.z > 17) targetPos.z = headPos.z - 5;
|
|
if (headPos.z < -17) targetPos.z = headPos.z + 5;
|
|
|
|
// 计算目标方向
|
|
const toTarget = new pc.Vec3().sub2(targetPos, headPos);
|
|
toTarget.y = 0;
|
|
|
|
if (toTarget.length() > 0.1) {
|
|
toTarget.normalize();
|
|
this.direction.lerp(this.direction, toTarget, 2 * dt);
|
|
this.direction.normalize();
|
|
}
|
|
|
|
// 移动头部
|
|
const move = this.direction.clone().scale(this.speed * dt);
|
|
headPos.add(move);
|
|
|
|
// 边界限制
|
|
headPos.x = pc.math.clamp(headPos.x, -19, 19);
|
|
headPos.z = pc.math.clamp(headPos.z, -19, 19);
|
|
|
|
this.head.setPosition(headPos);
|
|
|
|
// 朝向
|
|
const angle = Math.atan2(this.direction.x, this.direction.z) * pc.math.RAD_TO_DEG;
|
|
this.head.setEulerAngles(0, angle, 0);
|
|
|
|
// 记录历史位置
|
|
this.history.unshift(headPos.clone());
|
|
if (this.history.length > this.segments.length * 4 + 20) {
|
|
this.history.pop();
|
|
}
|
|
|
|
// 更新身体段
|
|
for (let i = 0; i < this.segments.length; i++) {
|
|
const idx = Math.min((i + 1) * 4, this.history.length - 1);
|
|
if (this.history[idx]) {
|
|
const targetPos = this.history[idx];
|
|
const currentPos = this.segments[i].getPosition();
|
|
currentPos.lerp(currentPos, targetPos, 0.3);
|
|
this.segments[i].setPosition(currentPos);
|
|
}
|
|
}
|
|
}
|
|
|
|
update(dt, targetPos) {
|
|
if (!gameRunning || !this.isAlive) return;
|
|
|
|
// AI使用自己的更新逻辑
|
|
if (this.isAI) {
|
|
this.aiUpdate(dt);
|
|
return;
|
|
}
|
|
|
|
// 减少无敌时间
|
|
if (this.invulnerableTime > 0) {
|
|
this.invulnerableTime -= dt;
|
|
}
|
|
|
|
// 计算目标方向
|
|
const headPos = this.head.getPosition();
|
|
const toTarget = new pc.Vec3().sub2(targetPos, headPos);
|
|
toTarget.y = 0;
|
|
|
|
if (toTarget.length() > 0.1) {
|
|
toTarget.normalize();
|
|
// 平滑转向
|
|
this.direction.lerp(this.direction, toTarget, 3 * dt);
|
|
this.direction.normalize();
|
|
}
|
|
|
|
// 移动头部
|
|
const move = this.direction.clone().scale(this.speed * dt);
|
|
headPos.add(move);
|
|
|
|
// 边界限制
|
|
headPos.x = pc.math.clamp(headPos.x, -19, 19);
|
|
headPos.z = pc.math.clamp(headPos.z, -19, 19);
|
|
|
|
this.head.setPosition(headPos);
|
|
|
|
// 朝向
|
|
const angle = Math.atan2(this.direction.x, this.direction.z) * pc.math.RAD_TO_DEG;
|
|
this.head.setEulerAngles(0, angle, 0);
|
|
|
|
// 记录历史位置
|
|
this.history.unshift(headPos.clone());
|
|
if (this.history.length > this.segments.length * 4 + 20) {
|
|
this.history.pop();
|
|
}
|
|
|
|
// 更新身体段 - 使用更平滑的跟随
|
|
for (let i = 0; i < this.segments.length; i++) {
|
|
const idx = Math.min((i + 1) * 4, this.history.length - 1);
|
|
if (this.history[idx]) {
|
|
const targetPos = this.history[idx];
|
|
const currentPos = this.segments[i].getPosition();
|
|
// 平滑插值
|
|
currentPos.lerp(currentPos, targetPos, 0.3);
|
|
this.segments[i].setPosition(currentPos);
|
|
}
|
|
}
|
|
|
|
// 相机跟随(只有玩家蛇控制相机)
|
|
if (camera && !this.isAI) {
|
|
const camPos = camera.getPosition();
|
|
camPos.x += (headPos.x - camPos.x) * 2 * dt;
|
|
camPos.z += (headPos.z - camPos.z) * 2 * dt;
|
|
camera.setPosition(camPos);
|
|
}
|
|
}
|
|
|
|
checkCollision(allSnakes) {
|
|
if (!this.isAlive) return;
|
|
const headPos = this.head.getPosition();
|
|
|
|
// 检查食物碰撞
|
|
for (let i = foods.length - 1; i >= 0; i--) {
|
|
const food = foods[i];
|
|
const foodPos = food.getPosition();
|
|
const foodScale = food.getLocalScale().x;
|
|
const dist = headPos.distance(foodPos);
|
|
|
|
// 食物大小影响碰撞距离
|
|
if (dist < this.radius + foodScale * 0.3) {
|
|
this.eatFood(i, food.foodValue);
|
|
}
|
|
}
|
|
|
|
// 无敌时间内不检测碰撞
|
|
if (this.invulnerableTime > 0) {
|
|
return;
|
|
}
|
|
|
|
// 检查与其他蛇的碰撞
|
|
for (let otherSnake of allSnakes) {
|
|
if (!otherSnake.isAlive) continue;
|
|
|
|
const otherHeadPos = otherSnake.head.getPosition();
|
|
|
|
// 头部碰撞检测(双方都死)
|
|
if (otherSnake !== this) {
|
|
const headDist = headPos.distance(otherHeadPos);
|
|
if (headDist < this.radius + otherSnake.radius) {
|
|
console.log('💥 头碰头!双方死亡');
|
|
// 播放头碰头音效(只给人类玩家播放,避免混音太乱)
|
|
if (!this.isAI || !otherSnake.isAI) {
|
|
playSound('headBump');
|
|
}
|
|
this.die();
|
|
otherSnake.die();
|
|
if (!this.isAI) window.gameOver();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 身体碰撞检测
|
|
// 检查对方身体(如果是自己,跳过前70%)
|
|
const startIdx = (otherSnake === this) ? Math.floor(otherSnake.segments.length * 0.7) : 0;
|
|
|
|
for (let i = startIdx; i < otherSnake.segments.length; i++) {
|
|
const segPos = otherSnake.segments[i].getPosition();
|
|
const dist = headPos.distance(segPos);
|
|
|
|
if (dist < this.radius * 0.7) {
|
|
if (otherSnake === this) {
|
|
console.log('💥 撞到自己!');
|
|
} else {
|
|
console.log('💥 撞到其他蛇!');
|
|
}
|
|
// 播放死亡音效(只给人类玩家)
|
|
if (!this.isAI) {
|
|
playSound('die');
|
|
}
|
|
this.die();
|
|
if (!this.isAI) window.gameOver();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
die() {
|
|
this.isAlive = false;
|
|
// 死亡效果:变暗
|
|
const mat = this.head.render.material;
|
|
mat.emissive = new pc.Color(0.1, 0.1, 0.1);
|
|
mat.update();
|
|
}
|
|
|
|
eatFood(index, value = 10) {
|
|
const food = foods[index];
|
|
app.root.removeChild(food);
|
|
foods.splice(index, 1);
|
|
|
|
// 更新总吃食物计数
|
|
this.totalEaten += value;
|
|
|
|
// 增长长度:比以前慢(每20分增加1段,而不是每10分)
|
|
const growth = Math.max(1, Math.floor(value / 20));
|
|
this.grow(growth);
|
|
|
|
// 增长速度变慢:每100分才增加0.1速度
|
|
this.speed += 0.01;
|
|
|
|
// 变胖效果:每吃100分,半径增加0.02,但有最大值限制
|
|
// 使用对数增长,避免太快变胖
|
|
const newRadius = this.baseRadius + Math.log(this.totalEaten / 50 + 1) * 0.05;
|
|
this.radius = Math.min(newRadius, this.maxRadius);
|
|
this.updateThickness();
|
|
|
|
// 播放吃食物音效(人类玩家吃大/小食物不同音效,AI简单音效)
|
|
if (!this.isAI) {
|
|
if (value >= 20) {
|
|
playSound('eatBig');
|
|
} else {
|
|
playSound('eat');
|
|
}
|
|
score += value;
|
|
window.updateUI();
|
|
}
|
|
|
|
// 生成新食物保持数量
|
|
spawnFood();
|
|
}
|
|
|
|
destroy() {
|
|
app.root.removeChild(this.head);
|
|
this.segments.forEach(seg => app.root.removeChild(seg));
|
|
}
|
|
}
|
|
|
|
// 食物生成 - 分大中小三种
|
|
function spawnFood() {
|
|
const food = new pc.Entity('Food' + foods.length);
|
|
food.addComponent('render', { type: 'sphere' });
|
|
|
|
// 随机决定食物大小:70%小、25%中、5%大
|
|
const rand = Math.random();
|
|
let scale, value, color;
|
|
|
|
if (rand < 0.05) {
|
|
// 大食物:5%概率,10分,橙色
|
|
scale = 0.8;
|
|
value = 30;
|
|
color = new pc.Color(1, 0.6, 0.1); // 橙色
|
|
} else if (rand < 0.3) {
|
|
// 中食物:25%概率,5分,粉色
|
|
scale = 0.6;
|
|
value = 20;
|
|
color = new pc.Color(1, 0.4, 0.7); // 粉色
|
|
} else {
|
|
// 小食物:70%概率,1分,随机颜色
|
|
scale = 0.4;
|
|
value = 10;
|
|
const colors = [
|
|
new pc.Color(1, 0.3, 0.3), // 红
|
|
new pc.Color(0.3, 1, 0.3), // 绿
|
|
new pc.Color(1, 1, 0.3), // 黄
|
|
new pc.Color(0.3, 1, 1) // 青
|
|
];
|
|
color = colors[Math.floor(Math.random() * colors.length)];
|
|
}
|
|
|
|
food.setLocalScale(scale, scale, scale);
|
|
food.foodValue = value;
|
|
|
|
// 在玩家周围生成食物,但不要太近
|
|
let x, z;
|
|
if (playerSnake && playerSnake.isAlive) {
|
|
const playerPos = playerSnake.head.getPosition();
|
|
// 在玩家周围15-30单位范围内生成
|
|
const angle = Math.random() * Math.PI * 2;
|
|
const dist = 15 + Math.random() * 15;
|
|
x = playerPos.x + Math.cos(angle) * dist;
|
|
z = playerPos.z + Math.sin(angle) * dist;
|
|
} else {
|
|
x = (Math.random() - 0.5) * 36;
|
|
z = (Math.random() - 0.5) * 36;
|
|
}
|
|
|
|
// 限制在地图范围内
|
|
x = pc.math.clamp(x, -19, 19);
|
|
z = pc.math.clamp(z, -19, 19);
|
|
|
|
food.setPosition(x, scale * 0.3, z);
|
|
|
|
const mat = new pc.StandardMaterial();
|
|
mat.diffuse = color;
|
|
mat.emissive = new pc.Color(color.r * 0.4, color.g * 0.4, color.b * 0.4);
|
|
mat.update();
|
|
food.render.material = mat;
|
|
|
|
// 浮动动画
|
|
food.floatOffset = Math.random() * Math.PI * 2;
|
|
|
|
app.root.addChild(food);
|
|
foods.push(food);
|
|
}
|
|
|
|
function updateFoods(dt) {
|
|
foods.forEach(food => {
|
|
const pos = food.getPosition();
|
|
pos.y = 0.2 + Math.sin(Date.now() * 0.003 + food.floatOffset) * 0.1;
|
|
food.setPosition(pos);
|
|
food.rotate(0, 60 * dt, 0);
|
|
});
|
|
}
|
|
|
|
// 输入处理
|
|
let targetPosition = new pc.Vec3(0, 0, 5);
|
|
let useJoystick = false;
|
|
let joystickCenter = { x: 0, y: 0 };
|
|
let joystickActive = false;
|
|
|
|
function setupInput() {
|
|
// 鼠标输入
|
|
app.mouse.on(pc.EVENT_MOUSEMOVE, (event) => {
|
|
if (!gameRunning || useJoystick) return;
|
|
|
|
const from = camera.camera.screenToWorld(event.x, event.y, camera.camera.nearClip);
|
|
const to = camera.camera.screenToWorld(event.x, event.y, camera.camera.farClip);
|
|
|
|
// 射线与地面交点
|
|
const dir = new pc.Vec3().sub2(to, from);
|
|
const t = -from.y / dir.y;
|
|
|
|
if (t > 0) {
|
|
targetPosition.set(
|
|
from.x + dir.x * t,
|
|
0,
|
|
from.z + dir.z * t
|
|
);
|
|
}
|
|
});
|
|
|
|
// 触摸/摇杆输入
|
|
const joystick = document.getElementById('joystick');
|
|
const knob = document.getElementById('joystick-knob');
|
|
|
|
joystick.addEventListener('touchstart', (e) => {
|
|
e.preventDefault();
|
|
useJoystick = true;
|
|
joystickActive = true;
|
|
const touch = e.touches[0];
|
|
const rect = joystick.getBoundingClientRect();
|
|
joystickCenter = {
|
|
x: rect.left + rect.width / 2,
|
|
y: rect.top + rect.height / 2
|
|
};
|
|
});
|
|
|
|
joystick.addEventListener('touchmove', (e) => {
|
|
e.preventDefault();
|
|
if (!joystickActive) return;
|
|
|
|
const touch = e.touches[0];
|
|
const dx = touch.clientX - joystickCenter.x;
|
|
const dy = touch.clientY - joystickCenter.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
const maxDist = 35;
|
|
|
|
const clampedDist = Math.min(dist, maxDist);
|
|
const angle = Math.atan2(dy, dx);
|
|
|
|
knob.style.transform = `translate(calc(-50% + ${Math.cos(angle) * clampedDist}px), calc(-50% + ${Math.sin(angle) * clampedDist}px))`;
|
|
|
|
// 设置目标方向
|
|
if (playerSnake) {
|
|
const headPos = playerSnake.head.getPosition();
|
|
targetPosition.set(
|
|
headPos.x + Math.cos(angle) * 10,
|
|
0,
|
|
headPos.z + Math.sin(angle) * 10
|
|
);
|
|
}
|
|
});
|
|
|
|
joystick.addEventListener('touchend', () => {
|
|
joystickActive = false;
|
|
knob.style.transform = 'translate(-50%, -50%)';
|
|
});
|
|
}
|
|
|
|
// 创建AI玩家
|
|
function spawnAIPlayers() {
|
|
// 清理已死亡的AI
|
|
aiSnakes = aiSnakes.filter(ai => {
|
|
if (!ai.isAlive) {
|
|
ai.destroy();
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// 补充AI到最大数量
|
|
let attempts = 0;
|
|
while (aiSnakes.length < maxAIPlayers && attempts < 10) {
|
|
attempts++;
|
|
|
|
// 随机位置,避免与玩家太近,也避免与其他AI太近
|
|
let x, z;
|
|
let validPosition = false;
|
|
let tryCount = 0;
|
|
|
|
while (!validPosition && tryCount < 20) {
|
|
tryCount++;
|
|
|
|
if (playerSnake && playerSnake.isAlive) {
|
|
const playerPos = playerSnake.head.getPosition();
|
|
const angle = Math.random() * Math.PI * 2;
|
|
const dist = 12 + Math.random() * 15;
|
|
x = playerPos.x + Math.cos(angle) * dist;
|
|
z = playerPos.z + Math.sin(angle) * dist;
|
|
} else {
|
|
x = (Math.random() - 0.5) * 35;
|
|
z = (Math.random() - 0.5) * 35;
|
|
}
|
|
|
|
x = pc.math.clamp(x, -18, 18);
|
|
z = pc.math.clamp(z, -18, 18);
|
|
|
|
// 检查是否与其他AI太近
|
|
validPosition = true;
|
|
const newPos = new pc.Vec3(x, 0, z);
|
|
for (let ai of aiSnakes) {
|
|
if (ai.isAlive) {
|
|
const dist = newPos.distance(ai.head.getPosition());
|
|
if (dist < 5) {
|
|
validPosition = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 只在找到有效位置时才创建AI
|
|
if (validPosition) {
|
|
const ai = new Snake(true, new pc.Vec3(x, 0.25, z));
|
|
aiSnakes.push(ai);
|
|
console.log('🤖 AI玩家生成,位置:', x.toFixed(1), z.toFixed(1));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 游戏控制(定义为全局函数供HTML调用)
|
|
window.startGame = function() {
|
|
console.log('🎮 开始多人对战游戏...');
|
|
|
|
// 初始化音频(需要用户交互后才能播放)
|
|
initAudio();
|
|
|
|
try {
|
|
document.getElementById('menu').style.display = 'none';
|
|
gameRunning = true;
|
|
score = 0;
|
|
|
|
// 清理旧游戏
|
|
if (playerSnake) playerSnake.destroy();
|
|
aiSnakes.forEach(ai => ai.destroy());
|
|
aiSnakes = [];
|
|
foods.forEach(f => {
|
|
if (f && f.parent) app.root.removeChild(f);
|
|
});
|
|
foods = [];
|
|
|
|
// 重置目标位置
|
|
targetPosition.set(5, 0, 0);
|
|
|
|
// 创建玩家蛇(青色)
|
|
console.log('🐍 创建玩家...');
|
|
playerSnake = new Snake(false);
|
|
console.log('✅ 玩家创建成功,长度:', playerSnake.segments.length);
|
|
|
|
// 创建AI玩家
|
|
console.log('🤖 创建AI玩家...');
|
|
spawnAIPlayers();
|
|
|
|
// 相机聚焦到玩家
|
|
if (camera && playerSnake) {
|
|
const headPos = playerSnake.head.getPosition();
|
|
camera.setPosition(headPos.x, 8, headPos.z);
|
|
}
|
|
|
|
// 生成食物
|
|
console.log('🍎 生成食物...');
|
|
for (let i = 0; i < maxFoods; i++) {
|
|
spawnFood();
|
|
}
|
|
console.log('✅ 生成', foods.length, '个食物');
|
|
|
|
updateUI();
|
|
console.log('✅ 游戏启动完成!');
|
|
console.log('🎮 玩法:移动鼠标控制蛇,吃食物变大,撞其他蛇身体会死,头碰头双方死');
|
|
|
|
// 通知 Poki 游戏开始
|
|
if (pokiReady && typeof PokiSDK !== 'undefined') {
|
|
PokiSDK.gameplayStart();
|
|
}
|
|
} catch (e) {
|
|
console.error('❌ 开始游戏失败:', e);
|
|
console.error(e.stack);
|
|
alert('游戏启动失败: ' + e.message);
|
|
}
|
|
};
|
|
|
|
window.showGameOver = function() {
|
|
document.getElementById('gameOver').style.display = 'block';
|
|
document.getElementById('finalScore').textContent = '最终得分: ' + score;
|
|
};
|
|
|
|
window.gameOver = function() {
|
|
if (!gameRunning) return; // 防止重复调用
|
|
|
|
console.log('💀 游戏结束!得分:', score);
|
|
gameRunning = false;
|
|
|
|
// 通知 Poki 游戏结束
|
|
if (pokiReady && typeof PokiSDK !== 'undefined') {
|
|
try {
|
|
PokiSDK.gameplayStop();
|
|
} catch(e) {}
|
|
}
|
|
|
|
// 显示广告
|
|
if (pokiReady && typeof PokiSDK !== 'undefined') {
|
|
PokiSDK.commercialBreak().then(() => {
|
|
window.showGameOver();
|
|
}).catch(() => {
|
|
window.showGameOver();
|
|
});
|
|
} else {
|
|
window.showGameOver();
|
|
}
|
|
};
|
|
|
|
window.restartGame = function() {
|
|
document.getElementById('gameOver').style.display = 'none';
|
|
window.startGame();
|
|
};
|
|
|
|
window.updateUI = function() {
|
|
if (playerSnake && playerSnake.isAlive) {
|
|
const thickness = Math.round((playerSnake.radius / playerSnake.baseRadius - 1) * 100);
|
|
document.getElementById('score').textContent = '得分: ' + score;
|
|
document.getElementById('length').textContent = '长度: ' + playerSnake.segments.length + ' | 胖瘦: +' + thickness + '%';
|
|
} else {
|
|
document.getElementById('score').textContent = '得分: ' + score;
|
|
document.getElementById('length').textContent = '长度: 0 | 胖瘦: 0%';
|
|
}
|
|
};
|
|
|
|
// 游戏循环
|
|
try {
|
|
setupScene();
|
|
console.log('✅ 场景设置完成');
|
|
|
|
setupInput();
|
|
console.log('✅ 输入设置完成');
|
|
} catch (e) {
|
|
console.error('❌ 场景初始化失败:', e);
|
|
}
|
|
|
|
// 调试计数器
|
|
let updateCount = 0;
|
|
let lastDebugTime = 0;
|
|
let lastAISpawnTime = 0;
|
|
|
|
app.on('update', (dt) => {
|
|
try {
|
|
if (gameRunning) {
|
|
// 获取所有活着的蛇
|
|
const allSnakes = [];
|
|
if (playerSnake && playerSnake.isAlive) allSnakes.push(playerSnake);
|
|
aiSnakes.forEach(ai => { if (ai.isAlive) allSnakes.push(ai); });
|
|
|
|
// 更新玩家
|
|
if (playerSnake && playerSnake.isAlive) {
|
|
playerSnake.update(dt, targetPosition);
|
|
playerSnake.checkCollision(allSnakes);
|
|
}
|
|
|
|
// 更新AI
|
|
aiSnakes.forEach(ai => {
|
|
if (ai.isAlive) {
|
|
ai.update(dt, null); // AI自己找目标
|
|
ai.checkCollision(allSnakes);
|
|
}
|
|
});
|
|
|
|
// 更新食物动画
|
|
updateFoods(dt);
|
|
|
|
// 定期补充AI(每3秒检查一次)
|
|
const now = Date.now();
|
|
if (now - lastAISpawnTime > 3000) {
|
|
spawnAIPlayers();
|
|
lastAISpawnTime = now;
|
|
}
|
|
|
|
// 保持食物数量
|
|
while (foods.length < maxFoods) {
|
|
spawnFood();
|
|
}
|
|
|
|
// 调试信息
|
|
updateCount++;
|
|
if (now - lastDebugTime > 5000) {
|
|
const aliveAI = aiSnakes.filter(ai => ai.isAlive).length;
|
|
console.log('🔄 游戏状态 - 食物:', foods.length, 'AI存活:', aliveAI, 'Update:', updateCount);
|
|
lastDebugTime = now;
|
|
updateCount = 0;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('❌ 游戏循环错误:', e);
|
|
console.error(e.stack);
|
|
}
|
|
});
|
|
|
|
console.log('🐍 Snake IO 游戏已加载!');
|
|
console.log('Poki SDK 状态:', pokiReady ? '✅ 已就绪' : '⚠️ 未就绪');
|
|
}
|
|
|
|
// 启动游戏初始化
|
|
initPoki().then(() => {
|
|
console.log('🎮 开始初始化游戏...');
|
|
});
|
|
</script>
|
|
<!-- 登录认证 -->
|
|
<script src="../auth.js?v=1"></script>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
GAME_AUTH.init('slither-io', '贪吃蛇');
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|