242 lines
6.8 KiB
Vue
242 lines
6.8 KiB
Vue
<script setup>
|
||
// 3D 极速赛车(Three.js):三车道公路,左右变道躲避车流,里程即分数
|
||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||
import * as THREE from 'three'
|
||
import { Keys, randInt } from '../engine'
|
||
|
||
const emit = defineEmits(['score', 'end'])
|
||
const holder = ref(null)
|
||
const W = 640
|
||
const H = 560
|
||
const LANES = [-2.2, 0, 2.2] // 三车道 X 坐标
|
||
let renderer, scene, camera, rafId
|
||
let keys
|
||
let myCar
|
||
let traffic = [] // 敌车 {mesh, lane, z, speed}
|
||
let stripes = [] // 车道虚线
|
||
let myLane = 1
|
||
let dist = 0
|
||
let speed = 26
|
||
let alive = false
|
||
let lastTs = 0
|
||
let spawnZ = -40
|
||
let shieldOn = false
|
||
let invincible = 0
|
||
let shieldRing
|
||
const CAR_COLORS = ['#ff5d73', '#ffd500', '#4ade80', '#b26bff', '#ff9f45']
|
||
|
||
// 组装一辆简单小车(车身+车顶)
|
||
function makeCar(color) {
|
||
const group = new THREE.Group()
|
||
const body = new THREE.Mesh(
|
||
new THREE.BoxGeometry(1.4, 0.5, 2.6),
|
||
new THREE.MeshLambertMaterial({ color })
|
||
)
|
||
body.position.y = 0.45
|
||
const top = new THREE.Mesh(
|
||
new THREE.BoxGeometry(1.1, 0.42, 1.3),
|
||
new THREE.MeshLambertMaterial({ color: '#dfe6ff' })
|
||
)
|
||
top.position.set(0, 0.85, 0.1)
|
||
group.add(body, top)
|
||
// 四个轮子
|
||
const wheelGeo = new THREE.CylinderGeometry(0.26, 0.26, 0.24, 14)
|
||
const wheelMat = new THREE.MeshLambertMaterial({ color: '#16161e' })
|
||
;[[-0.72, 0.9], [0.72, 0.9], [-0.72, -0.9], [0.72, -0.9]].forEach(([x, z]) => {
|
||
const wheel = new THREE.Mesh(wheelGeo, wheelMat)
|
||
wheel.rotation.z = Math.PI / 2
|
||
wheel.position.set(x, 0.26, z)
|
||
group.add(wheel)
|
||
})
|
||
return group
|
||
}
|
||
|
||
function initScene() {
|
||
scene = new THREE.Scene()
|
||
scene.background = new THREE.Color('#0d0e2b')
|
||
scene.fog = new THREE.Fog('#0d0e2b', 22, 60)
|
||
camera = new THREE.PerspectiveCamera(65, W / H, 0.1, 120)
|
||
camera.position.set(0, 4.6, 7.5)
|
||
camera.lookAt(0, 0, -6)
|
||
const dir = new THREE.DirectionalLight(0xffffff, 1.4)
|
||
dir.position.set(4, 10, 4)
|
||
scene.add(dir)
|
||
scene.add(new THREE.AmbientLight(0xffffff, 0.6))
|
||
// 公路
|
||
const road = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(8.2, 200),
|
||
new THREE.MeshLambertMaterial({ color: '#23253f' })
|
||
)
|
||
road.rotation.x = -Math.PI / 2
|
||
road.position.z = -70
|
||
scene.add(road)
|
||
// 路肩
|
||
const sideMat = new THREE.MeshLambertMaterial({ color: '#141530' })
|
||
;[-7, 7].forEach((x) => {
|
||
const side = new THREE.Mesh(new THREE.PlaneGeometry(6, 200), sideMat)
|
||
side.rotation.x = -Math.PI / 2
|
||
side.position.set(x, -0.01, -70)
|
||
scene.add(side)
|
||
})
|
||
// 车道虚线
|
||
const stripeMat = new THREE.MeshBasicMaterial({ color: '#8f95c9' })
|
||
for (let z = 8; z > -120; z -= 4) {
|
||
;[-1.1, 1.1].forEach((x) => {
|
||
const s = new THREE.Mesh(new THREE.PlaneGeometry(0.14, 1.6), stripeMat)
|
||
s.rotation.x = -Math.PI / 2
|
||
s.position.set(x, 0.01, z)
|
||
scene.add(s)
|
||
stripes.push(s)
|
||
})
|
||
}
|
||
// 我的车
|
||
myCar = makeCar('#00e5ff')
|
||
myCar.position.set(LANES[1], 0, 3.5)
|
||
scene.add(myCar)
|
||
// 护盾光环
|
||
shieldRing = new THREE.Mesh(
|
||
new THREE.TorusGeometry(1.5, 0.06, 10, 40),
|
||
new THREE.MeshBasicMaterial({ color: '#00e5ff' })
|
||
)
|
||
shieldRing.rotation.x = Math.PI / 2
|
||
shieldRing.visible = false
|
||
myCar.add(shieldRing)
|
||
}
|
||
|
||
function spawnTraffic() {
|
||
// 随机 1~2 辆占道(永远至少留一条通路)
|
||
const lanes = [0, 1, 2].sort(() => Math.random() - 0.5).slice(0, randInt(1, 2))
|
||
lanes.forEach((lane) => {
|
||
const car = makeCar(CAR_COLORS[randInt(0, CAR_COLORS.length - 1)])
|
||
car.position.set(LANES[lane], 0, spawnZ)
|
||
car.rotation.y = Math.PI // 对头方向摆放
|
||
scene.add(car)
|
||
traffic.push({ mesh: car, lane, z: spawnZ, speed: speed * 0.45 })
|
||
})
|
||
spawnZ -= randInt(14, 22)
|
||
}
|
||
|
||
function animate(ts) {
|
||
rafId = requestAnimationFrame(animate)
|
||
const dt = Math.min((ts - lastTs) / 1000 || 0.016, 0.05)
|
||
lastTs = ts
|
||
if (alive) {
|
||
if (invincible > 0) invincible -= dt
|
||
// 里程与提速
|
||
dist += speed * dt
|
||
speed = 26 + Math.min(30, dist * 0.02)
|
||
const score = Math.floor(dist)
|
||
emit('score', score)
|
||
// 我的车向目标车道平滑移动
|
||
const targetX = LANES[myLane]
|
||
myCar.position.x += (targetX - myCar.position.x) * Math.min(1, 10 * dt)
|
||
myCar.rotation.z = (targetX - myCar.position.x) * -0.08
|
||
// 车流相对接近
|
||
traffic.forEach((t) => {
|
||
t.z += (speed - t.speed) * dt
|
||
t.mesh.position.z = t.z
|
||
})
|
||
// 移除超过屏幕的车 & 补新车
|
||
traffic = traffic.filter((t) => {
|
||
if (t.z > 12) {
|
||
scene.remove(t.mesh)
|
||
return false
|
||
}
|
||
return true
|
||
})
|
||
if (!traffic.length || Math.max(...traffic.map((t) => t.z)) > -22) {
|
||
if (spawnZ > -60) spawnTraffic()
|
||
}
|
||
spawnZ = Math.min(spawnZ + speed * dt, -40)
|
||
// 虚线滚动(视觉速度感)
|
||
stripes.forEach((s) => {
|
||
s.position.z += speed * dt
|
||
if (s.position.z > 10) s.position.z -= 128
|
||
})
|
||
// 碰撞判定
|
||
if (invincible <= 0) {
|
||
for (const t of traffic) {
|
||
if (Math.abs(t.z - myCar.position.z) < 2.4 && Math.abs(LANES[t.lane] - myCar.position.x) < 1.4) {
|
||
if (shieldOn) {
|
||
shieldOn = false
|
||
shieldRing.visible = false
|
||
invincible = 1.2
|
||
scene.remove(t.mesh)
|
||
traffic = traffic.filter((x) => x !== t)
|
||
} else {
|
||
alive = false
|
||
emit('end', { score })
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
// 无敌闪烁
|
||
myCar.visible = invincible <= 0 || Math.floor(ts / 90) % 2 === 0
|
||
}
|
||
renderer.render(scene, camera)
|
||
}
|
||
|
||
// ---- 对外接口 ----
|
||
function start() {
|
||
traffic.forEach((t) => scene.remove(t.mesh))
|
||
traffic = []
|
||
myLane = 1
|
||
dist = 0
|
||
speed = 26
|
||
spawnZ = -40
|
||
shieldOn = false
|
||
invincible = 0
|
||
alive = true
|
||
myCar.position.set(LANES[1], 0, 3.5)
|
||
myCar.visible = true
|
||
shieldRing.visible = false
|
||
emit('score', 0)
|
||
}
|
||
function stop() {
|
||
cancelAnimationFrame(rafId)
|
||
keys?.detach()
|
||
renderer?.dispose()
|
||
}
|
||
// 复活:清空车流+2秒无敌;护盾:挡一次碰撞
|
||
function useProp(code) {
|
||
if (code === 'revive' && !alive) {
|
||
traffic.forEach((t) => scene.remove(t.mesh))
|
||
traffic = []
|
||
invincible = 2
|
||
alive = true
|
||
return true
|
||
}
|
||
if (code === 'shield' && alive && !shieldOn) {
|
||
shieldOn = true
|
||
shieldRing.visible = true
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
defineExpose({ start, stop, useProp })
|
||
|
||
onMounted(() => {
|
||
renderer = new THREE.WebGLRenderer({ antialias: true })
|
||
renderer.setSize(W, H)
|
||
holder.value.appendChild(renderer.domElement)
|
||
renderer.domElement.style.borderRadius = '10px'
|
||
renderer.domElement.style.maxWidth = '100%'
|
||
keys = new Keys()
|
||
keys.attach()
|
||
// 变道输入
|
||
keys.onPress((k) => {
|
||
if (!alive) return
|
||
if ((k === 'ArrowLeft' || k === 'a') && myLane > 0) myLane--
|
||
if ((k === 'ArrowRight' || k === 'd') && myLane < 2) myLane++
|
||
})
|
||
initScene()
|
||
animate(0)
|
||
})
|
||
onBeforeUnmount(stop)
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="holder"></div>
|
||
</template>
|