基類Entity
建立基類Entity,實現投影能力、動畫入場效果(從小變大的彈性動畫)、計算自己在地圖格位置的方法。
// 導入gsap動畫庫(用于創建補間動畫)
import gsap from 'gsap'// 定義Entity基類
export default class Entity {constructor(mesh, resolution, option = { size: 1.5, number: 0.5 }) {// 保存傳入的3D網格對象this.mesh = mesh// 配置網格的陰影屬性mesh.castShadow = true // 允許投射陰影mesh.receiveShadow = true // 允許接收陰影// 保存分辨率參數和相關配置選項this.resolution = resolution // 可能包含屏幕/布局分辨率信息this.option = option // 動畫參數,默認size=1.5, number=0.5}// 位置屬性的getter,代理訪問mesh的位置get position() {return this.mesh.position // 返回THREE.Vector3對象}// 根據坐標計算索引的方法(可能用于網格布局)getIndexByCoord() {const { x, y } = this.resolution // 解構分辨率,可能代表網格列/行數// 計算索引公式:z坐標 * x軸分辨率 + x坐標// 注意:此處可能存在問題,通常3D空間索引需要考慮y坐標return this.position.z * x + this.position.x}// 進入動畫方法in() {// 使用gsap創建縮放動畫gsap.from(this.mesh.scale, { // 從指定狀態動畫到當前狀態duration: 1, // 動畫時長1秒x: 0, // 初始X縮放為0y: 0, // 初始Y縮放為0 z: 0, // 初始Z縮放為0ease: `elastic.out(${this.option.size}, ${this.option.number})`, // 彈性緩動函數})}// 離開動畫方法(暫未實現)out() {}
}
開發糖果類Candy
Candy
這個類繼承了之前Entity,
這里用threejs內置的SphereGeometry(0.3, 20, 20)
做了個小圓球模型。然后用MeshStandardMaterial做材質,并可以傳入顏色
,所有糖果都會用這個模具和顏色來制作。并且可以隨機生成大小不一的糖果,代表不同的分數。
// 導入Three.js的3D對象相關模塊
import {Mesh, // 網格對象(幾何體+材質的組合)MeshNormalMaterial, // 顯示法向量的標準材質MeshStandardMaterial, // PBR標準材質SphereGeometry, // 球體幾何體
} from 'three'// 導入自定義的Entity基類
import Entity from './Entity'// 創建球體幾何體(半徑0.3,經緯分段數各20)
const GEOMETRY = new SphereGeometry(0.3, 20, 20)
// 創建標準材質并設置基礎顏色為紫色(#614bdd)
const MATERIAL = new MeshStandardMaterial({color: 0x614bdd,
})// 定義Candy類,繼承自Entity基類
export default class Candy extends Entity {constructor(resolution, color) {// 創建網格對象(使用共享的幾何體和材質)const mesh = new Mesh(GEOMETRY, MATERIAL)// 調用父類Entity的構造函數super(mesh, resolution) // 假設Entity處理了場景添加、位置初始化等// 如果有傳入顏色參數,則覆蓋材質的默認顏色// 注意:這里會修改共享材質,影響所有Candy實例!if (color) {MATERIAL.color.set(color)}// 生成隨機點數(1-3點)this.points = Math.floor(Math.random() * 3) + 1// 根據點數設置縮放比例:點數越多,糖果越大// 基礎縮放0.5 + (點數*0.5)/3 → 范圍在0.5~1.0之間this.mesh.scale.setScalar(0.5 + (this.points * 0.5) / 3)}
}
創建LinkedKList與ListNode類,實現鏈表結構,來模擬貪吃蛇相連的每節節點。
// 鏈表容器類(管理整個鏈)
export default class LinkedList {constructor(head) { // 初始化必須傳入頭節點this.head = head // 存儲鏈表頭部this.end = head // 存儲鏈表尾部(初始頭就是尾)}// 添加新節點方法(注意:只能向后追加)addNode(node) {this.end.linkTo(node) // 讓當前尾部連接新節點this.end = node // 更新尾部為新節點}
}
// 鏈表節點類(每個節點像一節火車車廂)
export default class ListNode {next = null // 指向下一個車廂的鉤子prev = null // 指向前一個車廂的鉤子constructor(data) {this.data = data // 當前車廂}// 連接節點方法(像車廂掛鉤的動作)linkTo(node) {this.next = node // 當前節點鉤住下一個node.prev = this // 下一個節點反鉤回來(形成雙向連接)}
}
Snake類
創建貪吃蛇本體,實現轉向,移動,吃糖果加尾部節點,碰到障礙物檢測的邏輯。需要用到之前的LinkedList、ListNode、Entity類。
import {EventDispatcher,Mesh,MeshNormalMaterial,MeshStandardMaterial,SphereGeometry,Vector2,Vector3,
} from 'three'
import LinkedList from './LinkedList'
import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry'
import ListNode from './ListNode'
import Entity from './Entity'const NODE_GEOMETRY = new RoundedBoxGeometry(0.9, 0.9, 0.9, 5, 0.1) // 用threejs內置函數創建圓角四方塊
const NODE_MATERIAL = new MeshStandardMaterial({color: 0xff470a,
}) // 創建材質并給初始顏色const UP = new Vector3(0, 0, -1)
const DOWN = new Vector3(0, 0, 1)
const LEFT = new Vector3(-1, 0, 0)
const RIGHT = new Vector3(1, 0, 0)
export default class Snake extends EventDispatcher {direction = RIGHT // 初始方向朝右indexes = [] // 存儲蛇身占用的格子坐標speedInterval = 240 // 移動速度(數值越大越慢)constructor({ scene, resolution = new Vector2(10, 10), color, mouthColor }) {super()// 把游戲場景、地圖尺寸、顏色存起來this.scene = scenethis.resolution = resolutionthis.mouthColor = mouthColor// 如果有顏色參數,給所有蛇節點刷顏色if (color) {NODE_MATERIAL.color.set(color)}this.init() // 開始組裝蛇}get head() {return this.body.head}get end() {return this.body.end}//組裝蛇頭createHeadMesh() {const headMesh = this.body.head.data.mesh // 獲取蛇頭模型// 造左眼:白眼球+黑眼珠const leftEye = new Mesh(new SphereGeometry(0.2, 10, 10),new MeshStandardMaterial({ color: 0xffffff }))leftEye.scale.x = 0.1leftEye.position.x = 0.5leftEye.position.y = 0.12leftEye.position.z = -0.1let leftEyeHole = new Mesh(new SphereGeometry(0.22, 10, 10),new MeshStandardMaterial({ color: 0x333333 }))leftEyeHole.scale.set(1, 0.6, 0.6)leftEyeHole.position.x += 0.05leftEye.add(leftEyeHole)// 造右眼:同上const rightEye = leftEye.clone()rightEye.position.x = -0.5rightEye.rotation.y = Math.PI// 造嘴巴:紫色圓角矩形const mouthMesh = new Mesh(new RoundedBoxGeometry(1.05, 0.1, 0.6, 5, 0.1),new MeshStandardMaterial({color: this.mouthColor, //0x614bdd,}))mouthMesh.rotation.x = -Math.PI * 0.07mouthMesh.position.z = 0.23mouthMesh.position.y = -0.19this.mouth = mouthMesh// 把零件裝到蛇頭上headMesh.add(rightEye, leftEye, mouthMesh)/* 調整頭部朝向當前方向 */headMesh.lookAt(headMesh.position.clone().add(this.direction))}init() {// 重置方向this.direction = RIGHTthis.iMoving = null// 造第一個蛇頭節點(放在地圖中心)const head = new ListNode(new SnakeNode(this.resolution))head.data.mesh.position.x = this.resolution.x / 2head.data.mesh.position.z = this.resolution.y / 2this.body = new LinkedList(head)this.createHeadMesh()this.indexes.push(this.head.data.getIndexByCoord())// 添加初始三節身體(類似火車車廂)for (let i = 0; i < 3; i++) {const position = this.end.data.mesh.position.clone()position.sub(this.direction) // 每節身體往后挪一格this.addTailNode() // 掛載到鏈表末尾this.end.data.mesh.position.copy(position)this.indexes.push(this.end.data.getIndexByCoord())}head.data.in()this.scene.add(head.data.mesh)}setDirection(keyCode) {let newDirection// 根據按鍵計算新方向(上下左右)switch (keyCode) {case 'ArrowUp':case 'KeyW':newDirection = UPbreakcase 'ArrowDown':case 'KeyS':newDirection = DOWNbreakcase 'ArrowLeft':case 'KeyA':newDirection = LEFTbreakcase 'ArrowRight':case 'KeyD':newDirection = RIGHTbreakdefault:return}const dot = this.direction.dot(newDirection)// 禁止180度掉頭(比如正在右走不能直接左轉)if (dot === 0) {this.newDirection = newDirection}}// 每幀判斷update() {// 應用新方向if (this.newDirection) {this.direction = this.newDirectionthis.newDirection = null}let currentNode = this.end// 處理吃糖果后的尾巴生長if (this.end.data.candy) {this.end.data.candy = nullthis.end.data.mesh.scale.setScalar(1)this.addTailNode()}// 身體像波浪一樣跟隨頭部移動while (currentNode.prev) {const candy = currentNode.prev.data.candyif (candy) {currentNode.data.candy = candycurrentNode.data.mesh.scale.setScalar(1.15)currentNode.prev.data.candy = nullcurrentNode.prev.data.mesh.scale.setScalar(1)}const position = currentNode.prev.data.mesh.position// 每節身體移動到前一節的位置currentNode.data.mesh.position.copy(position)currentNode = currentNode.prev // 往前遍歷}const headPos = currentNode.data.mesh.positionheadPos.add(this.direction)// currentNode.data.mesh.position.add(this.direction)const headMesh = this.body.head.data.meshheadMesh.lookAt(headMesh.position.clone().add(this.direction))// 移動頭部if (headPos.z < 0) {headPos.z = this.resolution.y - 1} else if (headPos.z > this.resolution.y - 1) {headPos.z = 0}// 邊界穿越處理(從地圖左邊消失就從地圖右邊出來...其他邊界類似)if (headPos.x < 0) {headPos.x = this.resolution.x - 1} else if (headPos.x > this.resolution.x - 1) {headPos.x = 0}this.updateIndexes()// 向上拋出事件this.dispatchEvent({ type: 'updated' })}// 死亡die() {let node = this.body.head// 移除所有身體部件do {this.scene.remove(node.data.mesh) // 從場景刪除模型node = node.next} while (node)this.init() // 重新初始化this.addEventListener({ type: 'die' }) // 向上拋出死亡事件}checkSelfCollision() {// 檢查頭部坐標是否和身體坐標重疊const headIndex = this.indexes.pop()const collide = this.indexes.includes(headIndex)this.indexes.push(headIndex)return collide // 撞到自己返回true}checkEntitiesCollision(entities) {// 檢查頭部坐標是否和障礙物坐標重疊const headIndex = this.indexes.at(-1)const entity = entities.find((entity) => entity.getIndexByCoord() === headIndex)return !!entity // 撞到障礙物返回true}// 更新蛇身所有節點的網格坐標索引,用于碰撞檢測等需要知道蛇身位置的功能updateIndexes() {// 清空舊索引(相當于擦除之前的身體痕跡)this.indexes = []// 從蛇尾開始遍歷(相當于從火車最后一節車廂開始檢查)let node = this.body.end// 循環向前遍歷所有節點(直到沒有前一節車廂)while (node) {// 獲取當前節點的網格坐標(比如把3D坐標轉換為地圖網格的[x,y])// 假設地圖是10x10網格,坐標(5.3, 0, 5.7)會被轉換為索引[5,5]// 將索引推入數組(記錄這節身體占據的格子)this.indexes.push(node.data.getIndexByCoord())// 移動到前一節身體(向蛇頭方向移動)node = node.prev// 最終得到的indexes數組示例:// [[3,5], [4,5], [5,5]] 表示蛇身占據這三個網格// 其中最后一個元素[5,5]是蛇頭位置}}// 添加尾部節點addTailNode(position) {const node = new ListNode(new SnakeNode(this.resolution))if (position) {node.data.mesh.position.copy(position)} else {node.data.mesh.position.copy(this.end.data.mesh.position)}this.body.addNode(node)node.data.in()this.scene.add(node.data.mesh)}
}
// 蛇身體節點類
class SnakeNode extends Entity {constructor(resolution) {const mesh = new Mesh(NODE_GEOMETRY, NODE_MATERIAL)super(mesh, resolution)}
}
實現障礙物,樹Tree與石頭Rock的類
// 導入Three.js相關模塊
import {IcosahedronGeometry, // 二十面體幾何體(適合制作復雜形狀)Mesh, // 網格對象(用于組合幾何體與材質)MeshNormalMaterial, // 法線材質(調試用,顯示表面朝向)MeshStandardMaterial, // PBR標準材質(支持金屬/粗糙度等特性)
} from 'three'
import Entity from './Entity' // 基礎實體類// 創建共享幾何體(優化性能,所有樹實例共用同一個幾何體)
const GEOMETRY = new IcosahedronGeometry(0.3) // 基礎半徑為0.3的二十面體
GEOMETRY.rotateX(Math.random() * Math.PI * 2) // 隨機繞X軸旋轉(避免重復感)
GEOMETRY.scale(1, 6, 1) // Y軸拉伸6倍,形成細長形狀(類似樹干)// 創建共享材質(所有樹實例默認使用相同材質)
const MATERIAL = new MeshStandardMaterial({flatShading: true, // 平面著色(增強低多邊形風格)color: 0xa2d109, // 默認黃綠色(類似樹葉顏色)
})// 定義樹類(繼承自基礎實體類)
export default class Tree extends Entity {constructor(resolution, color) {// 創建網格實例(組合幾何體與材質)const mesh = new Mesh(GEOMETRY, MATERIAL)// 隨機縮放(0.6~1.2倍原始尺寸,制造大小差異)mesh.scale.setScalar(0.6 + Math.random() * 0.6)// 隨機Y軸旋轉(讓樹木朝向不同方向)mesh.rotation.y = Math.random() * Math.PI * 2// 如果指定顏色,覆蓋默認材質顏色if (color) {MATERIAL.color.set(color)}// 調用父類構造函數(處理坐標轉換等)super(mesh, resolution)}
}
// 導入Three.js相關模塊
import {IcosahedronGeometry, // 二十面體幾何體(用于創建復雜形狀)Mesh, // 網格對象(組合幾何體與材質)MeshNormalMaterial, // 法線材質(調試用)MeshStandardMaterial, // PBR標準材質(支持金屬/粗糙度)
} from 'three'
import Entity from './Entity' // 基礎實體類// 創建共享幾何體(所有巖石實例共用)
const GEOMETRY = new IcosahedronGeometry(0.5) // 基礎半徑0.5的二十面體// 創建共享材質(所有巖石實例默認使用相同材質)
const MATERIAL = new MeshStandardMaterial({color: 0xacacac, // 默認巖石灰色flatShading: true, // 平面著色(增強低多邊形風格)
})// 巖石類(繼承基礎實體類)
export default class Rock extends Entity {constructor(resolution, color) {// 創建網格實例(幾何體+材質)const mesh = new Mesh(GEOMETRY, MATERIAL)// X軸:0.5~1倍隨機縮放(橫向隨機寬度)// Y軸:0.5~2.4倍縮放(使用平方讓更多巖石較矮)// Z軸:保持1倍(前后方向不變形)mesh.scale.set(Math.random() * 0.5 + 0.5, 0.5 + Math.random() ** 2 * 1.9, 1)mesh.rotation.y = Math.random() * Math.PI * 2 // 隨機Y軸旋轉(0-360度)mesh.rotation.x = Math.random() * Math.PI * 0.1 // 輕微X軸傾斜(最大18度)mesh.rotation.order = 'YXZ' // 旋轉順序:先Y后X最后Z(避免萬向鎖問題)// 下沉位置(使巖石看起來半埋在地面)mesh.position.y = -0.5// 如果指定顏色,覆蓋默認材質顏色if (color) {MATERIAL.color.set(color)}// 調用父類構造函數(處理坐標轉換等)super(mesh, resolution)}
}
最后,實現地圖尺寸配置Params.js,光照Lights等環境變量
// 導入Three.js二維向量模塊
import { Vector2 } from 'three'// 定義場景地圖尺寸參數 20*20的格子
const resolution = new Vector2(20, 20)
/*** 參數說明:* x: 場景橫向尺* y: 場景縱向尺寸*/// 定義顏色配置集合
const colors = {groundColor: '#ff7438', // 地面基礎色(暖橙色)fogColor: '#d68a4c' // 霧效顏色(與地面色協調的淺棕色)
}// 導出配置參數(供其他模塊統一訪問)
export { resolution, colors }
// 導入Three.js光源相關模塊
import { AmbientLight, DirectionalLight } from 'three'
import { resolution } from './Params' // 場景尺寸參數// 創建環境光(提供基礎照明,無方向性)
const ambLight = new AmbientLight(0xffffff, // 白光(十六進制顏色值)0.6 // 光照強度(范圍0-1,相當于60%亮度)
)// 創建平行光(模擬太陽光,產生方向性陰影)
const dirLight = new DirectionalLight(0xffffff, // 白光0.7 // 主光源強度(70%亮度)
)// 設置平行光參數
dirLight.position.set(20, 20, 18) // 光源三維坐標(模擬高空太陽位置)
dirLight.target.position.set( // 光照焦點位置(場景中心點)resolution.x / 2, // X軸中心(地圖寬度的一半)0, // Y軸保持地面高度resolution.y / 2 // Z軸中心(地圖深度的一半)
)// 陰影質量配置
dirLight.shadow.mapSize.set(1024, 1024) // 陰影貼圖分辨率(值越大越清晰,但更耗性能)
dirLight.shadow.radius = 7 // 陰影模糊半徑(軟化陰影邊緣)
dirLight.shadow.blurSamples = 20 // 模糊采樣次數(提升陰影邊緣平滑度)// 設置陰影相機的視錐范圍(控制產生陰影的區域)
dirLight.shadow.camera.top = 30 // 可見區域頂部邊界
dirLight.shadow.camera.bottom = -30 // 可見區域底部邊界
dirLight.shadow.camera.left = -30 // 可見區域左邊界
dirLight.shadow.camera.right = 30 // 可見區域右邊界dirLight.castShadow = true // 啟用該光源的陰影投射// 組合光源(通常場景需要多個光源配合)
const lights = [dirLight, ambLight]export default lights // 導出光源配置集合
最后是入口文件main.js的主邏輯,包含游戲循環,按鍵監聽,主題切換,游戲開始與失敗邏輯
import './style.css'// 導入Three.js核心庫和擴展模塊
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' // 攝像機軌道控制器
import Snake from './Snake' // 貪吃蛇游戲角色
import Candy from './Candy' // 糖果道具
import Rock from './Rock' // 巖石障礙物
import Tree from './Tree' // 樹木裝飾
import lights from './Lights' // 光照系統
import { resolution } from './Params' // 場景分辨率參數
import gsap from 'gsap' // 動畫庫(用于平滑過渡)
import { FontLoader } from 'three/examples/jsm/loaders/FontLoader' // 字體加載器
import fontSrc from 'three/examples/fonts/helvetiker_bold.typeface.json?url' // 3D字體文件
import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry' // 3D文字幾何體
import Entity from './Entity' // 基礎實體類// 設備檢測(移動端適配)
const isMobile = window.innerWidth <= 768
const loader = new FontLoader()
let font
// 字體加載系統
loader.load(fontSrc, function (loadedFont) {font = loadedFontprintScore() // 字體加載完成后初始化計分板
})// 配色方案集合(支持主題切換)
const palettes = {green: { // 綠色主題groundColor: 0x56f854,fogColor: 0x39c09f,rockColor: 0xebebeb,treeColor: 0x639541,candyColor: 0x1d5846,snakeColor: 0x1d5846,mouthColor: 0x39c09f,},orange: { // 橙色主題groundColor: 0xd68a4c,fogColor: 0xffac38,rockColor: 0xacacac,treeColor: 0xa2d109,candyColor: 0x614bdd,snakeColor: 0xff470a,mouthColor: 0x614bdd,},lilac: { // 紫色主題groundColor: 0xd199ff,fogColor: 0xb04ce6,rockColor: 0xebebeb,treeColor: 0x53d0c1,candyColor: 0x9900ff,snakeColor: 0xff2ed2,mouthColor: 0x614bdd,},
}// 主題管理系統
let paletteName = localStorage.getItem('paletteName') || 'green'
let selectedPalette = palettes[paletteName]const params = {...selectedPalette, // 當前生效的配色參數
}// 應用配色方案(支持運行時切換)
function applyPalette(paletteName) {const palette = palettes[paletteName]localStorage.setItem('paletteName', paletteName)selectedPalette = paletteif (!palette) return// 更新場景元素顏色const {groundColor,fogColor,rockColor,treeColor,candyColor,snakeColor,mouthColor,} = paletteplaneMaterial.color.set(groundColor) // 地面scene.fog.color.set(fogColor) // 霧效scene.background.set(fogColor) // 背景// 更新實體顏色(巖石、樹木等)entities.find((entity) => entity instanceof Rock)?.mesh.material.color.set(rockColor)entities.find((entity) => entity instanceof Tree)?.mesh.material.color.set(treeColor)// 更新游戲元素candies[0].mesh.material.color.set(candyColor)snake.body.head.data.mesh.material.color.set(snakeColor)snake.body.head.data.mesh.material.color.set(snakeColor)snake.mouthColor = mouthColorsnake.mouth.material.color.set(mouthColor)// 更新UI按鈕btnPlayImg.src = `/btn-play-bg-${paletteName}.png`
}
// 游戲核心參數
let score = 0 // 得分統計// 網格輔助線(可視化場景坐標系)
const gridHelper = new THREE.GridHelper(resolution.x, // 橫向分割數resolution.y, // 縱向分割數0xffffff, // 主網格顏色0xffffff // 次級網格顏色
)
gridHelper.position.set(resolution.x / 2 - 0.5, -0.49, resolution.y / 2 - 0.5) // 居中定位
gridHelper.material.transparent = true
gridHelper.material.opacity = isMobile ? 0.75 : 0.3 // 移動端降低透明度// 場景初始化
const scene = new THREE.Scene()
scene.background = new THREE.Color(params.fogColor) // 背景scene.fog = new THREE.Fog(params.fogColor, 5, 40) // 添加霧效scene.add(gridHelper) // 添加輔助網格// 視窗尺寸管理
const sizes = {width: window.innerWidth,height: window.innerHeight,
}// 攝像機系統
const fov = 60 // 正交相機(類似人眼)
const camera = new THREE.PerspectiveCamera(fov, sizes.width / sizes.height, 0.1)// 攝像機位置(移動端與PC不同)
const finalPosition = isMobile? new THREE.Vector3(resolution.x / 2 - 0.5, resolution.x + 15, resolution.y): new THREE.Vector3(-8 + resolution.x / 2,resolution.x / 2 + 4,resolution.y + 6)
const initialPosition = new THREE.Vector3(resolution.x / 2 + 5,4,resolution.y / 2 + 4
)
camera.position.copy(initialPosition)// 渲染器配置
const renderer = new THREE.WebGLRenderer({antialias: window.devicePixelRatio < 2, // 抗鋸齒(高清屏關閉)logarithmicDepthBuffer: true, // 解決遠距離渲染問題
})
document.body.appendChild(renderer.domElement)
handleResize() // 初始自適應// 高級渲染特性
renderer.toneMapping = THREE.ACESFilmicToneMapping // 電影級色調映射
renderer.toneMappingExposure = 1.2 // 曝光強度
renderer.shadowMap.enabled = true // 啟用陰影
renderer.shadowMap.type = THREE.VSMShadowMap // 柔和陰影算法// 攝像機控制器(限制移動方式)
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true // 慣性滑動
controls.enableZoom = false // 禁用縮放
controls.enablePan = false // 禁用平移
controls.enableRotate = false // 禁用旋轉
controls.target.set(resolution.x/2 -2, 0, resolution.y/2 + (isMobile ? 0 : 2))// 地面
const planeGeometry = new THREE.PlaneGeometry(resolution.x*50, resolution.y*50)
planeGeometry.rotateX(-Math.PI * 0.5) // 創建水平面為地面
const planeMaterial = new THREE.MeshStandardMaterial({color: params.groundColor
})
const plane = new THREE.Mesh(planeGeometry, planeMaterial)
plane.position.set(resolution.x/2-0.5, -0.5, resolution.y/2-0.5) // 對齊網格
plane.receiveShadow = true // 接收陰影
scene.add(plane)// 游戲角色初始化
const snake = new Snake({scene,resolution,color: selectedPalette.snakeColor,mouthColor: selectedPalette.mouthColor,
})// 游戲事件系統
snake.addEventListener('updated', function () {// 碰撞檢測,碰撞上障礙就死亡,重置游戲if (snake.checkSelfCollision() || snake.checkEntitiesCollision(entities)) {snake.die()resetGame()}// 糖果收集檢測const headIndex = snake.indexes.at(-1)const candyIndex = candies.findIndex((candy) => candy.getIndexByCoord() === headIndex)// 吃到糖果if (candyIndex >= 0) {const candy = candies[candyIndex]scene.remove(candy.mesh) // 從場景移除被吃掉的糖果candies.splice(candyIndex, 1)snake.body.head.data.candy = candyaddCandy() // 生成新的糖果score += candy.points // 加分printScore() // 更新分數板}
})let scoreEntity // 當前分數板function printScore() {// 等待字體加載完成if (!font) {return}if (!score) {score = 0}// 清理舊分數對象(避免內存泄漏)if (scoreEntity) {scene.remove(scoreEntity.mesh)scoreEntity.mesh.geometry.dispose() // 釋放幾何體內存scoreEntity.mesh.material.dispose() // 釋放材質內存}// 創建帶倒角的3D文字幾何體const geometry = new TextGeometry(`${score}`, {font: font,size: 1,depth: 0.1,curveSegments: 12,bevelEnabled: true, // 啟用倒角bevelThickness: 0.1, // 倒角深度bevelSize: 0.1, // 倒角寬度bevelOffset: 0,bevelSegments: 5,})geometry.center() // 幾何體居中if (isMobile) {geometry.rotateX(-Math.PI * 0.5) // 移動端縱向旋轉90度}// 復用蛇頭材質創建網格const mesh = new THREE.Mesh(geometry, snake.body.head.data.mesh.material)// 定位在場景上方mesh.position.x = resolution.x / 2 - 0.5mesh.position.z = -4mesh.position.y = 1.8// 啟用陰影投射mesh.castShadow = true// 創建分數實體并加入場景scoreEntity = new Entity(mesh, resolution, { size: 0.8, number: 0.3 })// 播放入場動畫scoreEntity.in()scene.add(scoreEntity.mesh)
}// 移動端觸摸控制系統 虛擬方向鍵DOM
const mobileArrows = document.getElementById('mobile-arrows')function registerEventListener() {if (isMobile) {// 觸摸邏輯核心參數const prevTouch = new THREE.Vector2() // 記錄上次觸摸位置let middle = 1.55 // 屏幕中心參考線let scale = 1 // 方向判斷靈敏度系數// 觸摸事件監聽window.addEventListener('touchstart', (event) => {const touch = event.targetTouches[0]middle = THREE.MathUtils.clamp(middle, 1.45, 1.65)// 將屏幕坐標轉換為歸一化坐標(-1到1范圍)let x = (2 * touch.clientX) / window.innerWidth - 1let y = (2 * touch.clientY) / window.innerHeight - middle// 游戲啟動檢測if (!isRunning) startGame()// 方向判斷算法(根據坐標象限)if (x * scale > y) {if (x * scale < -y) {snake.setDirection('ArrowUp')scale = 3} else {snake.setDirection('ArrowRight')middle += yscale = 0.33}} else {if (-x * scale > y) {snake.setDirection('ArrowLeft')middle += yscale = 0.33} else {snake.setDirection('ArrowDown')scale = 3}}prevTouch.x = xprevTouch.y = y // 記錄本次觸摸位置})} else {// 桌面端鍵盤事件監聽window.addEventListener('keydown', function (e) {const keyCode = e.codesnake.setDirection(keyCode) // 方向鍵控制if (keyCode === 'Space') { // 空格鍵暫停/繼續!isRunning ? startGame() : stopGame()} else if (!isRunning) {startGame()}})}
}let isRunning // 游戲運行狀態標記function startGame() {if (!snake.isMoving) {// 設置240ms間隔的蛇移動循環(約4幀/秒)isRunning = setInterval(() => {snake.update()}, 240)}
}// 暫停游戲
function stopGame() {clearInterval(isRunning) // 清除計時器isRunning = null // 重置狀態
}// 重置游戲
function resetGame() {stopGame() // 停止游戲循環score = 0 // 重置積分// 清理所有糖果對象let candy = candies.pop()while (candy) {scene.remove(candy.mesh)candy = candies.pop()}// 清理所有實體對象(巖石/樹木)let entity = entities.pop()while (entity) {scene.remove(entity.mesh)entity = entities.pop()}// 重新初始化游戲元素addCandy()generateEntities()
}const candies = [] // 糖果對象池
const entities = [] // 實體對象池(障礙物)// 糖果生成邏輯
function addCandy() {const candy = new Candy(resolution, selectedPalette.candyColor)let index = getFreeIndex() // 獲取可用位置索引// 設置三維坐標(基于索引的網格布局)candy.mesh.position.x = index % resolution.xcandy.mesh.position.z = Math.floor(index / resolution.x)candies.push(candy) // 加入對象池candy.in() // 播放生成動畫scene.add(candy.mesh)
}addCandy()// 隨機位置生成算法
function getFreeIndex() {let indexlet candyIndexes = candies.map((candy) => candy.getIndexByCoord())let entityIndexes = entities.map((entity) => entity.getIndexByCoord())// 生成隨機索引(范圍:0到場景總網格數)do {index = Math.floor(Math.random() * resolution.x * resolution.y)} while (snake.indexes.includes(index) || // 不與蛇身重疊candyIndexes.includes(index) || // 不與現有糖果重疊entityIndexes.includes(index) // 不與障礙物重疊)return index
}// 障礙物生成邏輯
function addEntity() {// 隨機生成巖石或樹木(50%概率)const entity =Math.random() > 0.5? new Rock(resolution, selectedPalette.rockColor): new Tree(resolution, selectedPalette.treeColor)let index = getFreeIndex() // 獲取安全位置// 設置坐標(與糖果生成邏輯相同)entity.mesh.position.x = index % resolution.xentity.mesh.position.z = Math.floor(index / resolution.x)entities.push(entity) // 加入對象池scene.add(entity.mesh) // 加入場景
}// 初始實體生成器
function generateEntities() {// 生成20個障礙物for (let i = 0; i < 20; i++) {addEntity()}// 按距離場景中心排序(優化渲染順序)entities.sort((a, b) => {const c = new THREE.Vector3(resolution.x / 2 - 0.5,0,resolution.y / 2 - 0.5)const distanceA = a.position.clone().sub(c).length()const distanceB = b.position.clone().sub(c).length()return distanceA - distanceB})// 使用GSAP實現彈性入場動畫gsap.from(entities.map((entity) => entity.mesh.scale),{x: 0,y: 0,z: 0,duration: 1,ease: 'elastic.out(1.5, 0.5)',stagger: {grid: [20, 20],amount: 0.7,},})
}generateEntities()// 添加光照
scene.add(...lights)// 生成裝飾性樹,地圖外的
const treeData = [new THREE.Vector4(-5, 0, 10, 1),new THREE.Vector4(-6, 0, 15, 1.2),new THREE.Vector4(-5, 0, 16, 0.8),new THREE.Vector4(-10, 0, 4, 1.3),new THREE.Vector4(-5, 0, -3, 2),new THREE.Vector4(-4, 0, -4, 1.5),new THREE.Vector4(-2, 0, -15, 1),new THREE.Vector4(5, 0, -20, 1.2),new THREE.Vector4(24, 0, -12, 1.2),new THREE.Vector4(2, 0, -6, 1.2),new THREE.Vector4(3, 0, -7, 1.8),new THREE.Vector4(1, 0, -9, 1.0),new THREE.Vector4(15, 0, -8, 1.8),new THREE.Vector4(17, 0, -9, 1.1),new THREE.Vector4(18, 0, -7, 1.3),new THREE.Vector4(24, 0, -1, 1.3),new THREE.Vector4(26, 0, 0, 1.8),new THREE.Vector4(32, 0, 0, 1),new THREE.Vector4(28, 0, 6, 1.7),new THREE.Vector4(24, 0, 15, 1.1),new THREE.Vector4(16, 0, 23, 1.1),new THREE.Vector4(12, 0, 24, 0.9),new THREE.Vector4(-13, 0, -13, 0.7),new THREE.Vector4(35, 0, 10, 0.7),
]
const tree = new Tree(resolution)treeData.forEach(({ x, y, z, w }) => {let clone = tree.mesh.clone()clone.position.set(x, y, z)clone.scale.setScalar(w)scene.add(clone)
})const rock = new Rock(resolution)
const resX = resolution.x
const rexY = resolution.y// 生成裝飾性石頭,地圖外的
const rockData = [[new THREE.Vector3(-7, -0.5, 2), new THREE.Vector4(2, 8, 3, 2.8)],[new THREE.Vector3(-3, -0.5, -10), new THREE.Vector4(3, 2, 2.5, 1.5)],[new THREE.Vector3(-5, -0.5, 3), new THREE.Vector4(1, 1.5, 2, 0.8)],[new THREE.Vector3(resX + 5, -0.5, 3), new THREE.Vector4(4, 1, 3, 1)],[new THREE.Vector3(resX + 4, -0.5, 2), new THREE.Vector4(2, 2, 1, 1)],[new THREE.Vector3(resX + 8, -0.5, 16), new THREE.Vector4(6, 2, 4, 4)],[new THREE.Vector3(resX + 6, -0.5, 13), new THREE.Vector4(3, 2, 2.5, 3.2)],[new THREE.Vector3(resX + 5, -0.5, -8), new THREE.Vector4(1, 1, 1, 0)],[new THREE.Vector3(resX + 6, -0.5, -7), new THREE.Vector4(2, 4, 1.5, 0.5)],[new THREE.Vector3(-5, -0.5, 14), new THREE.Vector4(1, 3, 2, 0)],[new THREE.Vector3(-4, -0.5, 15), new THREE.Vector4(0.8, 0.6, 0.7, 0)],[new THREE.Vector3(resX / 2 + 5, -0.5, 25),new THREE.Vector4(2.5, 0.8, 4, 2),],[new THREE.Vector3(resX / 2 + 9, -0.5, 22),new THREE.Vector4(1.2, 2, 1.2, 1),],[new THREE.Vector3(resX / 2 + 8, -0.5, 21.5),new THREE.Vector4(0.8, 1, 0.8, 2),],
]rockData.forEach(([position, { x, y, z, w }]) => {let clone = new Rock(resolution).meshclone.position.copy(position)clone.scale.set(x, y, z)clone.rotation.y = wscene.add(clone)
})// 音效
const audio = document.getElementById('audio')
const btnVolume = document.getElementById('btn-volume')
const btnPlay = document.getElementById('btn-play')
const btnPlayImg = document.getElementById('btn-play-img')
// 音效按鈕效果
gsap.fromTo(btnPlay,{ autoAlpha: 0, scale: 0, yPercent: -50, xPercent: -50 },{duration: 0.8,autoAlpha: 1,scale: 1,yPercent: -50,xPercent: -50,delay: 0.3,ease: `elastic.out(1.2, 0.7)`,}
)// 開始游戲
btnPlay.addEventListener('click', function () {audio.play()gsap.to(camera.position, { ...finalPosition, duration: 2 })if (isMobile) {gsap.to(controls.target, {x: resolution.x / 2 - 0.5,y: 0,z: resolution.y / 2 - 0.5,})}gsap.to(scene.fog, { duration: 2, near: isMobile ? 30 : 20, far: 55 })gsap.to(this, {duration: 1,scale: 0,ease: `elastic.in(1.2, 0.7)`,onComplete: () => {this.style.visibility = 'hidden'},})registerEventListener()
})const userVolume = localStorage.getItem('volume')
if (userVolume === 'off') {muteVolume()
}// 音量
const initialVolume = audio.volumebtnVolume.addEventListener('click', function () {if (audio.volume === 0) {unmuteVolume()} else {muteVolume()}
})// 靜音
function muteVolume() {localStorage.setItem('volume', 'off')gsap.to(audio, { volume: 0, duration: 1 })btnVolume.classList.remove('after:hidden')btnVolume.querySelector(':first-child').classList.remove('animate-ping')btnVolume.classList.add('after:block')
}// 解除靜音
function unmuteVolume() {localStorage.setItem('volume', 'on')btnVolume.classList.add('after:hidden')btnVolume.querySelector(':first-child').classList.add('animate-ping')btnVolume.classList.remove('after:block')gsap.to(audio, { volume: initialVolume, duration: 1 })
}// 主題選擇
const topBar = document.querySelector('.top-bar')
const paletteSelectors = document.querySelectorAll('[data-color]')
gsap.to(topBar, {opacity: 1,delay: 0.5,onComplete: () => {gsap.to(paletteSelectors, {duration: 1,x: 0,autoAlpha: 1,ease: `elastic.out(1.2, 0.9)`,stagger: {amount: 0.2,},})},
})paletteSelectors.forEach((selector) =>selector.addEventListener('click', function () {const paletteName = this.dataset.colorapplyPalette(paletteName)})
)// 加載器
const manager = new THREE.LoadingManager()
const textureLoader = new THREE.TextureLoader(manager)// 按鍵新手引導
const wasd = textureLoader.load('/wasd.png')
const arrows = textureLoader.load('/arrows.png')const wasdGeometry = new THREE.PlaneGeometry(3.5, 2)
wasdGeometry.rotateX(-Math.PI * 0.5)const planeWasd = new THREE.Mesh(wasdGeometry,new THREE.MeshStandardMaterial({transparent: true,map: wasd,opacity: isMobile ? 0 : 0.5,})
)const planeArrows = new THREE.Mesh(wasdGeometry,new THREE.MeshStandardMaterial({transparent: true,map: arrows,opacity: isMobile ? 0 : 0.5,})
)planeArrows.position.set(8.7, 0, 21)
planeWasd.position.set(13, 0, 21)// 添加按鍵新手引導
scene.add(planeArrows, planeWasd)// 使用主題
applyPalette(paletteName)// 游戲主循環
function tic() {controls.update()renderer.render(scene, camera)requestAnimationFrame(tic)
}requestAnimationFrame(tic)// 監聽屏幕尺寸變化
window.addEventListener('resize', handleResize)function handleResize() {sizes.width = window.innerWidthsizes.height = window.innerHeightcamera.aspect = sizes.width / sizes.heightcamera.updateProjectionMatrix()renderer.setSize(sizes.width, sizes.height)const pixelRatio = Math.min(window.devicePixelRatio, 2)renderer.setPixelRatio(pixelRatio)
}
游戲 截圖如上
游戲源碼地址:GitCode - 全球開發者的開源社區,開源代碼托管平臺
游戲預覽地址:3D貪吃蛇
創作不易,點個贊再走吧