refactor web

This commit is contained in:
bridge
2025-11-21 01:38:41 +08:00
parent 5a51b6638d
commit 41d2103ffc
14 changed files with 863 additions and 559 deletions

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { useTextures } from './composables/useTextures'
import { ref, watch, onMounted } from 'vue'
import { Graphics, Ticker } from 'pixi.js'
import { useApplication } from 'vue3-pixi'
import { ref, watch } from 'vue'
import { Graphics } from 'pixi.js'
import type { Avatar } from '../../types/game'
import { useSharedTicker } from './composables/useSharedTicker'
const props = defineProps<{
avatar: any
avatar: Avatar
tileSize: number
}>()
@@ -14,7 +15,6 @@ const emit = defineEmits<{
}>()
const { textures } = useTextures()
const app = useApplication()
// Target position (grid coordinates)
const targetX = ref(props.avatar.x)
@@ -30,34 +30,23 @@ watch(() => [props.avatar.x, props.avatar.y], ([newX, newY]) => {
targetY.value = newY
})
// Animation Loop
onMounted(() => {
const ticker = new Ticker()
ticker.add((delta) => {
const destX = targetX.value * props.tileSize + props.tileSize / 2
const destY = targetY.value * props.tileSize + props.tileSize / 2
// Simple Lerp for smoothness
// Speed factor: 0.1 means it covers 10% of the remaining distance per frame
const speed = 0.1 * delta.deltaTime
if (Math.abs(destX - currentX.value) > 1) {
currentX.value += (destX - currentX.value) * speed
} else {
currentX.value = destX
}
if (Math.abs(destY - currentY.value) > 1) {
currentY.value += (destY - currentY.value) * speed
} else {
currentY.value = destY
}
})
ticker.start()
useSharedTicker((delta) => {
const destX = targetX.value * props.tileSize + props.tileSize / 2
const destY = targetY.value * props.tileSize + props.tileSize / 2
// Cleanup manually if needed, though Vue unmount should handle parent destruction
// Ideally we should attach to app.ticker, but local ticker is easier for per-component logic without memory leaks if managed well.
// Better approach: use onTick from vue3-pixi if available, or just requestAnimationFrame
const speed = 0.1 * delta
if (Math.abs(destX - currentX.value) > 1) {
currentX.value += (destX - currentX.value) * speed
} else {
currentX.value = destX
}
if (Math.abs(destY - currentY.value) > 1) {
currentY.value += (destY - currentY.value) * speed
} else {
currentY.value = destY
}
})
function getTexture() {

View File

@@ -1,141 +1,136 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { onMounted, ref, watch } from 'vue'
import { Container, Sprite } from 'pixi.js'
import { useTextures } from './composables/useTextures'
import { useMapData } from '../../composables/useMapData'
import type { Region } from '../../types/game'
const TILE_SIZE = 64
const mapContainer = ref<Container>()
const { textures, isLoaded, loadSectTexture } = useTextures()
const TILE_SIZE = 64
const regions = ref<any[]>([])
const { mapTiles, regions, isMapLoaded, loadMapData } = useMapData()
const regionStyleCache = new Map<string, Record<string, unknown>>()
const emit = defineEmits<{
(e: 'mapLoaded', payload: { width: number, height: number }): void
(e: 'regionSelected', payload: { type: 'region'; id: string; name?: string }): void
}>()
async function initMap() {
if (!mapContainer.value || !isLoaded.value) return
try {
const res = await fetch('/api/map')
const data = await res.json()
const mapData = data.data
regions.value = data.regions || []
// 1. 预加载所有宗门的纹理
const loadPromises: Promise<void>[] = []
for (const r of regions.value) {
if (r.type === 'sect' && r.sect_name) {
// 使用 sect_name宗门名而不是 name总部名来加载图片
loadPromises.push(loadSectTexture(r.sect_name))
}
}
await Promise.all(loadPromises)
if (!mapData) return
// Imperative Tile Rendering
mapContainer.value.removeChildren()
const rows = mapData.length
const cols = mapData[0].length
console.log(`Rendering Map: ${cols}x${rows}`)
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const type = mapData[y][x]
// 占位符直接跳过,不渲染任何东西(让背景透出来,或者就空着)
if (type === 'PLACEHOLDER') continue
let tex = textures.value[type]
// 特殊处理 SECT 类型
if (type === 'SECT') {
const r = regions.value.find(r =>
r.type === 'sect' && Math.abs(r.x - x) < 3 && Math.abs(r.y - y) < 3
)
if (r && r.sect_name) {
// 使用 sect_name宗门名而不是 name总部名来匹配图片
const sectKey = `SECT_${r.sect_name}`
if (textures.value[sectKey]) {
tex = textures.value[sectKey]
} else {
tex = textures.value['CITY']
}
} else {
tex = textures.value['CITY']
}
}
if (!tex) tex = textures.value['PLAIN']
if (tex) {
const s = new Sprite(tex)
s.x = x * TILE_SIZE
s.y = y * TILE_SIZE
// 2x2 大地块渲染逻辑
if (['SECT', 'CITY', 'CAVE', 'RUINS'].includes(type)) {
s.width = TILE_SIZE * 2
s.height = TILE_SIZE * 2
// 确保层级正确,大建筑可以稍微调整 zIndex 如果有深度排序需求
// 但在这里 tile 是平铺的,只要顺序对就行
} else {
s.width = TILE_SIZE
s.height = TILE_SIZE
}
s.eventMode = 'none'
mapContainer.value.addChild(s)
}
}
}
emit('mapLoaded', {
width: cols * TILE_SIZE,
height: rows * TILE_SIZE
})
} catch (e) {
console.error("Map load error", e)
onMounted(() => {
loadMapData().catch((error) => console.error('Map load error', error))
if (isLoaded.value && isMapLoaded.value) {
renderMap()
}
})
watch(
() => [isLoaded.value, isMapLoaded.value],
([texturesReady, mapReady]) => {
if (texturesReady && mapReady) {
renderMap()
}
}
)
async function renderMap() {
if (!mapContainer.value || !mapTiles.value.length) return
await preloadSectTextures(regions.value)
mapContainer.value.removeChildren()
const rows = mapTiles.value.length
const cols = mapTiles.value[0]?.length ?? 0
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const type = mapTiles.value[y][x]
if (type === 'PLACEHOLDER') continue
let tex = textures.value[type]
if (type === 'SECT') {
tex = resolveSectTexture(x, y) ?? textures.value['CITY']
}
if (!tex) {
tex = textures.value['PLAIN']
}
if (!tex) continue
const sprite = new Sprite(tex)
sprite.x = x * TILE_SIZE
sprite.y = y * TILE_SIZE
if (['SECT', 'CITY', 'CAVE', 'RUINS'].includes(type)) {
sprite.width = TILE_SIZE * 2
sprite.height = TILE_SIZE * 2
} else {
sprite.width = TILE_SIZE
sprite.height = TILE_SIZE
}
sprite.eventMode = 'none'
mapContainer.value.addChild(sprite)
}
}
emit('mapLoaded', {
width: cols * TILE_SIZE,
height: rows * TILE_SIZE
})
}
watch(isLoaded, (val) => {
if (val) initMap()
})
async function preloadSectTextures(regionList: Region[]) {
const sectNames = Array.from(
new Set(
regionList
.filter(region => region.type === 'sect' && region.sect_name)
.map(region => region.sect_name as string)
)
)
await Promise.all(sectNames.map(name => loadSectTexture(name)))
}
onMounted(() => {
if (isLoaded.value) initMap()
})
function resolveSectTexture(x: number, y: number) {
const region = regions.value.find(r =>
r.type === 'sect' && Math.abs(r.x - x) < 3 && Math.abs(r.y - y) < 3
)
if (region?.sect_name) {
const key = `SECT_${region.sect_name}`
return textures.value[key] ?? null
}
return null
}
function getRegionStyle(type: string) {
const base = {
fontFamily: '"Microsoft YaHei", sans-serif',
fontSize: type === 'sect' ? 48 : 64,
fill: type === 'sect' ? '#ffcc00' : (type === 'city' ? '#ccffcc' : '#ffffff'),
stroke: { color: '#000000', width: 8, join: 'round' },
align: 'center',
dropShadow: {
color: '#000000',
blur: 4,
angle: Math.PI / 6,
distance: 4,
alpha: 0.8
}
if (regionStyleCache.has(type)) {
return regionStyleCache.get(type)
}
const style = {
fontFamily: '"Microsoft YaHei", sans-serif',
fontSize: type === 'sect' ? 48 : 64,
fill: type === 'sect' ? '#ffcc00' : (type === 'city' ? '#ccffcc' : '#ffffff'),
stroke: { color: '#000000', width: 8, join: 'round' },
align: 'center',
dropShadow: {
color: '#000000',
blur: 4,
angle: Math.PI / 6,
distance: 4,
alpha: 0.8
}
return base
}
regionStyleCache.set(type, style)
return style
}
function handleRegionSelect(region: any) {
emit('regionSelected', {
type: 'region',
id: String(region.id),
name: region.name
})
function handleRegionSelect(region: Region) {
emit('regionSelected', {
type: 'region',
id: String(region.id),
name: region.name
})
}
</script>

View File

@@ -0,0 +1,28 @@
import { Ticker } from 'pixi.js'
import { onMounted, onUnmounted } from 'vue'
const sharedTicker = new Ticker()
let consumerCount = 0
export function useSharedTicker(callback: (delta: number) => void) {
const runner = (ticker: Ticker) => {
callback(ticker.deltaTime)
}
onMounted(() => {
consumerCount += 1
sharedTicker.add(runner)
if (!sharedTicker.started) {
sharedTicker.start()
}
})
onUnmounted(() => {
sharedTicker.remove(runner)
consumerCount = Math.max(consumerCount - 1, 0)
if (consumerCount === 0) {
sharedTicker.stop()
}
})
}

View File

@@ -30,28 +30,29 @@ export function useTextures() {
'FARM': '/assets/tiles/farm.png'
}
// 加载基础地图纹理
for (const [key, url] of Object.entries(manifest)) {
const tilePromises = Object.entries(manifest).map(async ([key, url]) => {
try {
textures.value[key] = await Assets.load(url)
} catch (e) {
console.error(`Failed to load texture: ${url}`, e)
} catch (error) {
console.error(`Failed to load texture: ${url}`, error)
}
}
})
// 加载角色立绘 (1-16)
for (let i = 1; i <= 16; i++) {
const avatarPromises = Array.from({ length: 16 }, (_, index) => index + 1).map(async (i) => {
const maleUrl = `/assets/males/${i}.png`
const femaleUrl = `/assets/females/${i}.png`
try {
textures.value[`male_${i}`] = await Assets.load(maleUrl)
} catch (e) { /* ignore */ }
await Promise.allSettled([
Assets.load(maleUrl).then((tex) => {
textures.value[`male_${i}`] = tex
}),
Assets.load(femaleUrl).then((tex) => {
textures.value[`female_${i}`] = tex
})
])
})
try {
textures.value[`female_${i}`] = await Assets.load(femaleUrl)
} catch (e) { /* ignore */ }
}
await Promise.all([...tilePromises, ...avatarPromises])
isLoaded.value = true
console.log('Base textures loaded')

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { useGameStore } from '../../stores/game'
const store = useGameStore()
</script>
<template>
<header class="top-bar">
<div class="left">
<span class="title">修仙模拟器</span>
<span class="status-dot" :class="{ connected: store.isConnected }"></span>
</div>
<div class="center">
<span class="time">{{ store.year }} {{ store.month }}</span>
</div>
<div class="right">
<span>修士: {{ store.avatarList.length }}</span>
</div>
</header>
</template>
<style scoped>
.top-bar {
height: 36px;
background: #1f1f1f;
border-bottom: 1px solid #333;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
font-size: 14px;
z-index: 10;
}
.top-bar .title {
font-weight: bold;
margin-right: 8px;
}
.status-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #ff4d4f;
}
.status-dot.connected {
background: #52c41a;
}
</style>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useGameStore } from '../../stores/game'
import { NSelect } from 'naive-ui'
const store = useGameStore()
const filterValue = ref('all')
const filterOptions = computed(() => [
{ label: '所有人', value: 'all' },
...store.avatarList.map(avatar => ({ label: avatar.name ?? avatar.id, value: avatar.id }))
])
const filteredEvents = computed(() => {
const allEvents = Array.isArray(store.events) ? store.events : []
if (filterValue.value === 'all') {
return allEvents
}
return allEvents.filter(event => event.relatedAvatarIds.includes(filterValue.value))
})
const emptyEventMessage = computed(() => (
filterValue.value === 'all' ? '暂无事件' : '该修士暂无事件'
))
</script>
<template>
<section class="sidebar-section">
<div class="sidebar-header">
<h3>事件记录</h3>
<n-select
v-model:value="filterValue"
:options="filterOptions"
size="tiny"
class="event-filter"
/>
</div>
<div v-if="filteredEvents.length === 0" class="empty">{{ emptyEventMessage }}</div>
<div v-else class="event-list">
<div v-for="event in filteredEvents" :key="event.id" class="event-item">
{{ event.content || event.text }}
</div>
</div>
</section>
</template>
<style scoped>
.sidebar-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #222;
border-bottom: 1px solid #333;
}
.sidebar-header h3 {
margin: 0;
font-size: 13px;
white-space: nowrap;
}
.event-filter {
width: 200px;
}
.event-list {
flex: 1;
overflow-y: auto;
padding: 8px 12px;
}
.event-item {
font-size: 14px;
line-height: 1.6;
color: #ddd;
padding: 6px 0;
border-bottom: 1px solid #2a2a2a;
white-space: pre-line;
}
.event-item:last-child {
border-bottom: none;
}
.empty {
padding: 20px;
text-align: center;
color: #666;
font-size: 12px;
}
</style>