1. 角色移动:WASD/方向键 控制移动。

2. 碰撞限制:地图边界 + 障碍物碰撞阻挡。
  3. 区域切换:根据位置切换区域名(训练平原/东侧坡道/西侧荒地)。
  4. 事件占位:传送点接触提示、随机遇敌计量与触发提示(为后续战斗切入预留)。
  5. UI联动:状态面板实时显示坐标、移速、区域、遇敌计量和提示信息。
  6. 构建验证:npm run build 已通过。
This commit is contained in:
zwt13703 2026-04-11 00:43:13 +08:00
parent f4a9faab18
commit 823e4c5090
6 changed files with 191 additions and 11 deletions

View File

@ -42,3 +42,14 @@
3. 安装依赖并修复 TypeScript 构建问题,完成生产构建验证。
- **执行结果**: 项目已可运行并可构建,页面可显示基础 3D 场景与 RPG 调试面板,作为后续探索/战斗系统开发起点。
# 任务执行摘要
## 会话 ID: 1775839250
- [2026-04-11 00:40:50]
- **执行原因**: 用户要求“继续完成”,希望在现有骨架上推进可玩功能。
- **执行过程**:
1. 新增输入系统与共享游戏状态,支持键盘移动与状态同步展示。
2. 扩展 Three.js 场景逻辑,加入角色移动、边界与障碍碰撞、区域切换、传送点占位和随机遇敌计量。
3. 更新状态面板为实时数据显示,并执行构建验证确保可编译运行。
- **执行结果**: 已完成探索层 MVP玩家可在 3D 场景中移动并获得基础 RPG 反馈,项目继续可在此基础上对接战斗系统。

34
src/core/input.ts Normal file
View File

@ -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 }
}
}

View File

@ -1,4 +1,4 @@
export const gameConfig = {
title: 'Breath of Fire-like RPG Demo',
tagline: '里程碑 0: Vue + TypeScript + WebGL 基础骨架',
tagline: '里程碑 1: 可移动探索 + 碰撞 + 遇敌占位',
}

View File

@ -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)

14
src/game/state.ts Normal file
View File

@ -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

View File

@ -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>