Files
cultivation-world-simulator/web/src/components/game/AnimatedAvatar.vue
2026-01-11 18:54:34 +08:00

228 lines
6.2 KiB
Vue

<script setup lang="ts">
import { useTextures } from './composables/useTextures'
import { ref, watch, computed } from 'vue'
import { Graphics } from 'pixi.js'
import type { AvatarSummary } from '../../types/core'
import { useSharedTicker } from './composables/useSharedTicker'
import { avatarIdToColor } from '../../utils/eventHelper'
const props = defineProps<{
avatar: AvatarSummary
tileSize: number
offset?: { x: number; y: number }
}>()
const emit = defineEmits<{
(e: 'select', payload: { type: 'avatar'; id: string; name?: string }): void
}>()
const { textures, availableAvatars } = useTextures()
// Target position (grid coordinates)
const targetX = ref(props.avatar.x)
const targetY = ref(props.avatar.y)
// Current render position (pixel coordinates)
// Initial position includes offset immediately to avoid "jumping" on spawn if possible,
// but props.offset might be undefined initially.
const initialOffsetX = props.offset?.x ?? 0
const initialOffsetY = props.offset?.y ?? 0
const currentX = ref((props.avatar.x + initialOffsetX) * props.tileSize + props.tileSize / 2)
const currentY = ref((props.avatar.y + initialOffsetY) * props.tileSize + props.tileSize / 2)
// Watch for prop updates (server ticks)
watch(() => [props.avatar.x, props.avatar.y], ([newX, newY]) => {
targetX.value = newX
targetY.value = newY
})
useSharedTicker((delta) => {
const offsetX = props.offset?.x ?? 0
const offsetY = props.offset?.y ?? 0
const destX = (targetX.value + offsetX) * props.tileSize + props.tileSize / 2
const destY = (targetY.value + offsetY) * props.tileSize + props.tileSize / 2
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
}
// Emoji bobbing animation
emojiTime += delta * 0.05
emojiBob.value = Math.sin(emojiTime) * 5
})
let emojiTime = 0
const emojiBob = ref(0)
function getTexture() {
const gender = (props.avatar.gender || 'male').toLowerCase()
let pid = props.avatar.pic_id
// Fallback logic if pic_id is missing
if (!pid) {
const list = availableAvatars.value[gender === 'female' ? 'females' : 'males']
if (list && list.length > 0) {
let hash = 0
const str = props.avatar.id || props.avatar.name || 'default'
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
pid = list[Math.abs(hash) % list.length]
} else {
pid = 1
}
}
const key = `${gender}_${pid}`
return textures.value[key]
}
function getScale() {
const tex = getTexture()
if (!tex) return 1
// Scale up: 3.5x tile size
return (props.tileSize * 3.5) / Math.max(tex.width, tex.height)
}
const drawFallback = (g: Graphics) => {
g.clear()
g.circle(0, 0, props.tileSize * 0.5)
g.fill({ color: props.avatar.gender === 'female' ? 0xffaaaa : 0xaaaaff })
g.stroke({ width: 2, color: 0x000000 })
}
const nameStyle = computed(() => ({
fontFamily: '"Microsoft YaHei", sans-serif',
fontSize: 50,
fontWeight: 'bold',
fill: avatarIdToColor(props.avatar.id),
stroke: { color: '#000000', width: 4 },
align: 'center',
dropShadow: {
color: '#000000',
blur: 2,
angle: Math.PI / 6,
distance: 2,
alpha: 0.8
}
} as any))
function handlePointerTap() {
emit('select', {
type: 'avatar',
id: props.avatar.id,
name: props.avatar.name
})
}
const emojiStyle = {
fontFamily: '"Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif',
fontSize: 70,
align: 'center',
} as any
const drawEmojiBg = (g: Graphics) => {
g.clear()
const w = 80
const h = 80
const r = 16
const halfW = w / 2
const halfH = h / 2
// 1. Draw all fills first (to cover background)
g.beginPath()
g.roundRect(-halfW, -halfH, w, h, r)
g.fill({ color: 0xffffff, alpha: 1.0 })
// Tail fill
g.beginPath()
g.moveTo(-halfW + 10, halfH) // Start at bottom-left area of body
g.lineTo(-halfW - 10, halfH + 20) // Point pointing down-left
g.lineTo(-halfW, halfH - 10) // Back to left edge of body
g.closePath()
g.fill({ color: 0xffffff, alpha: 1.0 })
// 2. Draw Strokes (Outlines)
// We draw the bubble body stroke
g.roundRect(-halfW, -halfH, w, h, r)
g.stroke({ width: 3, color: 0x000000, alpha: 1.0 })
// We draw the tail stroke
g.beginPath()
g.moveTo(-halfW + 10, halfH)
g.lineTo(-halfW - 10, halfH + 20)
g.lineTo(-halfW, halfH - 10)
g.stroke({ width: 3, color: 0x000000, alpha: 1.0 })
// 3. Clean up the intersection with a white patch
// We fill a small polygon over the line where tail meets body
g.beginPath()
g.moveTo(-halfW + 8, halfH - 2) // Inside body, near bottom
g.lineTo(-halfW - 2, halfH - 12) // Inside body, near left
g.lineTo(-halfW - 8, halfH + 16) // Towards tail tip (but not all the way)
g.lineTo(-halfW + 8, halfH + 2) // Towards tail base
g.closePath()
g.fill({ color: 0xffffff, alpha: 1.0 })
}
</script>
<template>
<container
:x="currentX"
:y="currentY"
:z-index="Math.floor(currentY)"
event-mode="static"
cursor="pointer"
@pointertap="handlePointerTap"
>
<sprite
v-if="getTexture()"
:texture="getTexture()"
:anchor-x="0.5"
:anchor-y="0.9"
:scale="getScale()"
/>
<graphics
v-else
@render="drawFallback"
/>
<!-- Emoji Bubble -->
<container
v-if="avatar.action_emoji"
:x="tileSize * 0.6"
:y="(getTexture() ? -tileSize * 3.5 : -tileSize * 1.2) + emojiBob"
:z-index="100"
>
<graphics @render="drawEmojiBg" />
<text
:text="avatar.action_emoji"
:style="emojiStyle"
:anchor="0.5"
:scale="1.0"
/>
</container>
<text
:text="avatar.name"
:style="nameStyle"
:anchor-x="0.5"
:anchor-y="0"
:y="10"
/>
</container>
</template>