1. 角色移动:WASD/方向键 控制移动。
2. 碰撞限制:地图边界 + 障碍物碰撞阻挡。 3. 区域切换:根据位置切换区域名(训练平原/东侧坡道/西侧荒地)。 4. 事件占位:传送点接触提示、随机遇敌计量与触发提示(为后续战斗切入预留)。 5. UI联动:状态面板实时显示坐标、移速、区域、遇敌计量和提示信息。 6. 构建验证:npm run build 已通过。
This commit is contained in:
parent
f4a9faab18
commit
823e4c5090
|
|
@ -42,3 +42,14 @@
|
|||
3. 安装依赖并修复 TypeScript 构建问题,完成生产构建验证。
|
||||
- **执行结果**: 项目已可运行并可构建,页面可显示基础 3D 场景与 RPG 调试面板,作为后续探索/战斗系统开发起点。
|
||||
|
||||
# 任务执行摘要
|
||||
|
||||
## 会话 ID: 1775839250
|
||||
- [2026-04-11 00:40:50]
|
||||
- **执行原因**: 用户要求“继续完成”,希望在现有骨架上推进可玩功能。
|
||||
- **执行过程**:
|
||||
1. 新增输入系统与共享游戏状态,支持键盘移动与状态同步展示。
|
||||
2. 扩展 Three.js 场景逻辑,加入角色移动、边界与障碍碰撞、区域切换、传送点占位和随机遇敌计量。
|
||||
3. 更新状态面板为实时数据显示,并执行构建验证确保可编译运行。
|
||||
- **执行结果**: 已完成探索层 MVP,玩家可在 3D 场景中移动并获得基础 RPG 反馈,项目继续可在此基础上对接战斗系统。
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
export class KeyboardInput {
|
||||
private readonly pressed = new Set<string>()
|
||||
|
||||
private readonly onKeyDown = (event: KeyboardEvent) => {
|
||||
this.pressed.add(event.code)
|
||||
}
|
||||
|
||||
private readonly onKeyUp = (event: KeyboardEvent) => {
|
||||
this.pressed.delete(event.code)
|
||||
}
|
||||
|
||||
start() {
|
||||
window.addEventListener('keydown', this.onKeyDown)
|
||||
window.addEventListener('keyup', this.onKeyUp)
|
||||
}
|
||||
|
||||
stop() {
|
||||
window.removeEventListener('keydown', this.onKeyDown)
|
||||
window.removeEventListener('keyup', this.onKeyUp)
|
||||
this.pressed.clear()
|
||||
}
|
||||
|
||||
getMoveAxis() {
|
||||
let x = 0
|
||||
let z = 0
|
||||
|
||||
if (this.pressed.has('KeyW') || this.pressed.has('ArrowUp')) z -= 1
|
||||
if (this.pressed.has('KeyS') || this.pressed.has('ArrowDown')) z += 1
|
||||
if (this.pressed.has('KeyA') || this.pressed.has('ArrowLeft')) x -= 1
|
||||
if (this.pressed.has('KeyD') || this.pressed.has('ArrowRight')) x += 1
|
||||
|
||||
return { x, z }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export const gameConfig = {
|
||||
title: 'Breath of Fire-like RPG Demo',
|
||||
tagline: '里程碑 0: Vue + TypeScript + WebGL 基础骨架',
|
||||
tagline: '里程碑 1: 可移动探索 + 碰撞 + 遇敌占位',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,61 @@
|
|||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import * as THREE from 'three'
|
||||
import type { Object3D } from 'three'
|
||||
import type { Mesh, Object3D } from 'three'
|
||||
import { GameLoop } from '../core/loop'
|
||||
import { KeyboardInput } from '../core/input'
|
||||
import { useGameState } from './state'
|
||||
|
||||
const root = ref<HTMLDivElement | null>(null)
|
||||
const gameState = useGameState()
|
||||
|
||||
let renderer: THREE.WebGLRenderer | null = null
|
||||
let camera: THREE.PerspectiveCamera | null = null
|
||||
let scene: THREE.Scene | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let loop: GameLoop | null = null
|
||||
let keyboard: KeyboardInput | null = null
|
||||
|
||||
const playerRadius = 0.45
|
||||
const worldBound = 7.2
|
||||
let encounterTarget = 45
|
||||
|
||||
const obstacleDefs = [
|
||||
{ x: -2.4, z: 1.5, radius: 0.9 },
|
||||
{ x: 1.8, z: -2.2, radius: 1.1 },
|
||||
{ x: 3.1, z: 2.7, radius: 0.8 },
|
||||
]
|
||||
|
||||
const canMoveTo = (nextX: number, nextZ: number) => {
|
||||
if (Math.abs(nextX) > worldBound || Math.abs(nextZ) > worldBound) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const obstacle of obstacleDefs) {
|
||||
const dx = nextX - obstacle.x
|
||||
const dz = nextZ - obstacle.z
|
||||
const minDistance = playerRadius + obstacle.radius
|
||||
if (dx * dx + dz * dz < minDistance * minDistance) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const updateArea = (x: number) => {
|
||||
if (x > 2.5) {
|
||||
gameState.area = '东侧坡道'
|
||||
return
|
||||
}
|
||||
|
||||
if (x < -2.5) {
|
||||
gameState.area = '西侧荒地'
|
||||
return
|
||||
}
|
||||
|
||||
gameState.area = '训练平原'
|
||||
}
|
||||
|
||||
const initScene = () => {
|
||||
if (!root.value) return
|
||||
|
|
@ -47,6 +92,23 @@ const initScene = () => {
|
|||
hero.position.y = 0.55
|
||||
scene.add(hero)
|
||||
|
||||
const portal = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(0.75, 0.12, 16, 36),
|
||||
new THREE.MeshStandardMaterial({ color: '#5f9df7', emissive: '#27466f', emissiveIntensity: 0.65 }),
|
||||
)
|
||||
portal.position.set(5.3, 0.8, 5.3)
|
||||
portal.rotation.x = Math.PI / 2
|
||||
scene.add(portal)
|
||||
|
||||
for (const obstacle of obstacleDefs) {
|
||||
const stone = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(obstacle.radius, obstacle.radius, 1.2, 16),
|
||||
new THREE.MeshStandardMaterial({ color: '#4d5d69' }),
|
||||
)
|
||||
stone.position.set(obstacle.x, 0.6, obstacle.z)
|
||||
scene.add(stone)
|
||||
}
|
||||
|
||||
const grid = new THREE.GridHelper(16, 16, '#4e6878', '#24323c')
|
||||
scene.add(grid)
|
||||
|
||||
|
|
@ -63,9 +125,56 @@ const initScene = () => {
|
|||
resizeObserver.observe(root.value)
|
||||
resize()
|
||||
|
||||
keyboard = new KeyboardInput()
|
||||
keyboard.start()
|
||||
|
||||
const cameraOffset = new THREE.Vector3(4.5, 5.2, 6.2)
|
||||
|
||||
loop = new GameLoop((dt) => {
|
||||
hero.rotation.y += dt * 0.8
|
||||
hero.position.y = 0.55 + Math.sin(performance.now() * 0.002) * 0.1
|
||||
const axis = keyboard?.getMoveAxis() ?? { x: 0, z: 0 }
|
||||
const hasMoveInput = axis.x !== 0 || axis.z !== 0
|
||||
|
||||
if (hasMoveInput) {
|
||||
const len = Math.hypot(axis.x, axis.z)
|
||||
const dirX = axis.x / len
|
||||
const dirZ = axis.z / len
|
||||
|
||||
const nextX = hero.position.x + dirX * gameState.moveSpeed * dt
|
||||
const nextZ = hero.position.z + dirZ * gameState.moveSpeed * dt
|
||||
|
||||
if (canMoveTo(nextX, nextZ)) {
|
||||
hero.position.x = nextX
|
||||
hero.position.z = nextZ
|
||||
|
||||
const movedDistance = Math.hypot(dirX * gameState.moveSpeed * dt, dirZ * gameState.moveSpeed * dt)
|
||||
gameState.encounterMeter += movedDistance * 13
|
||||
|
||||
if (gameState.encounterMeter >= encounterTarget) {
|
||||
gameState.encounterMeter = 0
|
||||
encounterTarget = 35 + Math.random() * 30
|
||||
gameState.notice = '触发遇敌(占位):可切入战斗场景'
|
||||
}
|
||||
} else {
|
||||
gameState.notice = '前方有障碍,无法通过'
|
||||
}
|
||||
|
||||
hero.rotation.y = Math.atan2(axis.x, axis.z)
|
||||
}
|
||||
|
||||
portal.rotation.z += dt
|
||||
|
||||
if (hero.position.distanceTo(portal.position) < 1.1) {
|
||||
gameState.notice = '进入传送点(占位):可切换场景'
|
||||
}
|
||||
|
||||
updateArea(hero.position.x)
|
||||
gameState.positionText = `(${hero.position.x.toFixed(1)}, ${hero.position.z.toFixed(1)})`
|
||||
|
||||
if (camera) {
|
||||
const cameraTarget = hero.position.clone().add(cameraOffset)
|
||||
camera.position.lerp(cameraTarget, 0.06)
|
||||
camera.lookAt(hero.position.x, 0.5, hero.position.z)
|
||||
}
|
||||
|
||||
if (renderer && scene && camera) {
|
||||
renderer.render(scene, camera)
|
||||
|
|
@ -77,6 +186,7 @@ const initScene = () => {
|
|||
|
||||
const disposeScene = () => {
|
||||
loop?.stop()
|
||||
keyboard?.stop()
|
||||
resizeObserver?.disconnect()
|
||||
|
||||
if (root.value && renderer) {
|
||||
|
|
@ -84,7 +194,7 @@ const disposeScene = () => {
|
|||
}
|
||||
|
||||
scene?.traverse((object: Object3D) => {
|
||||
const mesh = object as THREE.Mesh
|
||||
const mesh = object as Mesh
|
||||
if (mesh.geometry) mesh.geometry.dispose()
|
||||
|
||||
const material = mesh.material
|
||||
|
|
@ -102,6 +212,7 @@ const disposeScene = () => {
|
|||
scene = null
|
||||
resizeObserver = null
|
||||
loop = null
|
||||
keyboard = null
|
||||
}
|
||||
|
||||
onMounted(initScene)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { reactive } from 'vue'
|
||||
|
||||
const state = reactive({
|
||||
hp: '120 / 120',
|
||||
mp: '38 / 38',
|
||||
level: 1,
|
||||
area: '训练平原',
|
||||
positionText: '(0.0, 0.0)',
|
||||
moveSpeed: 3.2,
|
||||
encounterMeter: 0,
|
||||
notice: '按 WASD 或方向键移动',
|
||||
})
|
||||
|
||||
export const useGameState = () => state
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
const stats = {
|
||||
hp: '120 / 120',
|
||||
mp: '38 / 38',
|
||||
level: 1,
|
||||
area: '训练平原',
|
||||
}
|
||||
import { useGameState } from '../game/state'
|
||||
|
||||
const stats = useGameState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -15,7 +12,11 @@ const stats = {
|
|||
<li>HP: {{ stats.hp }}</li>
|
||||
<li>MP: {{ stats.mp }}</li>
|
||||
<li>区域: {{ stats.area }}</li>
|
||||
<li>坐标: {{ stats.positionText }}</li>
|
||||
<li>移速: {{ stats.moveSpeed.toFixed(1) }}</li>
|
||||
<li>遇敌计量: {{ stats.encounterMeter.toFixed(0) }}</li>
|
||||
</ul>
|
||||
<p class="notice">{{ stats.notice }}</p>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
|
|
@ -40,4 +41,13 @@ ul {
|
|||
gap: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 10px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: #20313d;
|
||||
color: #9bc2da;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in New Issue