pet/src/components/PetGame.vue

667 lines
19 KiB
Vue
Raw Normal View History

2025-11-20 07:01:22 +00:00
<template>
<div class="pet-game-container" ref="containerRef">
<!-- 寵物本體 -->
<div
class="pet-root"
ref="petRef"
:style="{
left: petX + 'px',
top: petY + 'px',
width: width + 'px',
height: height + 'px',
display: state === 'dead' ? 'none' : 'block'
}"
:class="['state-' + state, { 'shaking-head': isShakingHead }]"
>
<div class="pet-inner" :class="isFacingRight ? 'face-right' : 'face-left'">
<!-- 根據是否張嘴選擇顯示的像素 -->
<div
v-for="(pixel, index) in currentPixels"
:key="index"
:class="['pet-pixel', pixel.className]"
:style="{
width: pixelSize + 'px',
height: pixelSize + 'px',
left: pixel.x * pixelSize + 'px',
top: pixel.y * pixelSize + 'px',
background: pixel.color
}"
></div>
</div>
</div>
<!-- 食物 -->
<div
v-if="state === 'eating' && foodVisible"
class="food-item"
:style="{
left: foodX + 'px',
top: foodY + 'px',
width: (10 * pixelSize) + 'px',
height: (10 * pixelSize) + 'px'
}"
>
<div
v-for="(pixel, index) in currentFoodPixels"
:key="'food-'+index"
class="pet-pixel"
:style="{
width: pixelSize + 'px',
height: pixelSize + 'px',
left: pixel.x * pixelSize + 'px',
top: pixel.y * pixelSize + 'px',
background: pixel.color
}"
></div>
</div>
<!-- 睡覺 ZZZ -->
<div class="sleep-zzz" :style="iconStyle" v-show="state === 'sleep'">
<span class="z1">Z</span>
<span class="z2">Z</span>
<span class="z3">Z</span>
</div>
<!-- 生病骷髏頭 -->
<div class="sick-icon" :style="iconStyle" v-show="state === 'sick'">💀</div>
<!-- 死亡墓碑 -->
<div class="tombstone" v-show="state === 'dead'"></div>
<!-- Debug Overlay -->
<div class="debug-overlay">
{{ containerWidth }}x{{ containerHeight }} | {{ Math.round(petX) }},{{ Math.round(petY) }} | {{ state }}
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { SPRITE_PRESETS } from '../data/petPresets.js';
import { FOOD_OPTIONS } from '../data/foodOptions.js';
const props = defineProps({
state: {
type: String,
default: 'idle'
},
stage: {
type: String,
default: 'adult' // 'egg' or 'adult'
}
});
const emit = defineEmits(['update:state']);
// Default food choice
const currentFood = 'banana'; // Can be: 'apple', 'banana', 'strawberry'
const FOOD_SPRITES = FOOD_OPTIONS[currentFood].sprites;
const FOOD_PALETTE = FOOD_OPTIONS[currentFood].palette;
const CURRENT_PRESET = SPRITE_PRESETS.tinyTigerCatB;
const pixelSize = CURRENT_PRESET.pixelSize;
// Define dimensions
const rows = CURRENT_PRESET.sprite.length;
const cols = CURRENT_PRESET.sprite[0].length;
const width = cols * pixelSize;
const height = rows * pixelSize;
// 2. Generate Pixels Helper
function generatePixels(spriteMap, paletteOverride = null) {
const pxs = [];
const palette = paletteOverride || CURRENT_PRESET.palette;
spriteMap.forEach((rowStr, y) => {
[...rowStr].forEach((ch, x) => {
if (ch === '0') return;
// Only apply body part classes if NOT an egg
let className = '';
if (props.stage !== 'egg') {
const isTail = CURRENT_PRESET.tailPixels?.some(([tx, ty]) => tx === x && ty === y);
const isLegFront = CURRENT_PRESET.legFrontPixels?.some(([lx, ly]) => lx === x && ly === y);
const isLegBack = CURRENT_PRESET.legBackPixels?.some(([lx, ly]) => lx === x && ly === y);
const isEar = CURRENT_PRESET.earPixels?.some(([ex, ey]) => ex === x && ey === y);
const isBlush = CURRENT_PRESET.blushPixels?.some(([bx, by]) => bx === x && by === y);
if (isTail) className += ' tail-pixel';
if (isLegFront) className += ' leg-front';
if (isLegBack) className += ' leg-back';
if (isEar) className += ' ear-pixel';
if (isBlush) className += ' blush-dot';
}
pxs.push({
x, y,
color: palette[ch] || '#000',
className: className.trim()
});
});
});
return pxs;
}
// 3. State & Movement
const containerRef = ref(null);
const petX = ref(0);
const petY = ref(0);
const isFacingRight = ref(true);
const iconX = ref(0);
const iconY = ref(0);
const isShakingHead = ref(false); // 控制搖頭時停止其他動畫
// Debug Refs
const containerWidth = ref(0);
const containerHeight = ref(0);
// Feeding State
const isMouthOpen = ref(false);
const foodX = ref(0);
const foodY = ref(0);
const foodStage = ref(0); // 0, 1, 2
const foodVisible = ref(false);
const currentPixels = computed(() => {
if (props.stage === 'egg') {
return generatePixels(CURRENT_PRESET.eggSprite, CURRENT_PRESET.eggPalette);
}
return isMouthOpen.value
? generatePixels(CURRENT_PRESET.spriteMouthOpen)
: generatePixels(CURRENT_PRESET.sprite);
});
const currentFoodPixels = computed(() => {
const sprite = FOOD_SPRITES[foodStage.value];
const pxs = [];
if(!sprite) return pxs;
sprite.forEach((row, y) => {
[...row].forEach((ch, x) => {
if (ch === '0') return;
pxs.push({
x, y,
color: FOOD_PALETTE[ch] || '#d00' // Use food palette
});
});
});
return pxs;
});
const iconStyle = computed(() => ({
left: iconX.value + 'px',
top: iconY.value + 'px'
}));
function updateHeadIconsPosition() {
const { iconBackLeft, iconBackRight } = CURRENT_PRESET;
const marker = isFacingRight.value ? iconBackLeft : iconBackRight;
const baseX = petX.value + marker.x * pixelSize;
const baseY = petY.value + marker.y * pixelSize;
if (props.state === 'sleep') {
iconX.value = baseX - 2;
iconY.value = baseY - 10;
} else if (props.state === 'sick') {
iconX.value = baseX - 2;
iconY.value = baseY - 28; // Moved higher to avoid overlap with pet body
}
}
function moveRandomly() {
// Egg does not move
if (props.stage === 'egg') {
// Force center position just in case
if (containerRef.value) {
const cw = containerRef.value.clientWidth;
const ch = containerRef.value.clientHeight;
petX.value = Math.floor(cw / 2 - width / 2);
petY.value = Math.floor(ch / 2 - height / 2);
}
return;
}
console.log('moveRandomly called. State:', props.state);
if (props.state === 'sleep' || props.state === 'dead' || props.state === 'eating') {
updateHeadIconsPosition();
return;
}
if (!containerRef.value) {
console.warn('containerRef is null');
return;
}
const cw = containerRef.value.clientWidth;
const ch = containerRef.value.clientHeight;
containerWidth.value = cw;
containerHeight.value = ch;
console.log('Container size:', cw, ch);
// Safety check: if container has no size, don't run logic to avoid sticking to (8,8)
if (cw === 0 || ch === 0) return;
const margin = 10;
const step = 4;
// 0: up, 1: down, 2: left, 3: right
const dir = Math.floor(Math.random() * 4);
let newX = petX.value;
let newY = petY.value;
if (dir === 0) newY -= step;
if (dir === 1) newY += step;
if (dir === 2) newX -= step;
if (dir === 3) newX += step;
const maxX = cw - width - margin;
const maxY = ch - height - margin;
const minX = margin;
const minY = margin;
newX = Math.max(minX, Math.min(maxX, newX));
newY = Math.max(minY, Math.min(maxY, newY));
const dx = newX - petX.value;
if (dx > 0) isFacingRight.value = false;
else if (dx < 0) isFacingRight.value = true;
petX.value = newX;
petY.value = newY;
updateHeadIconsPosition();
}
// Feeding Logic
async function startFeeding() {
// Reset food
foodStage.value = 0;
foodVisible.value = true;
// Calculate food position: in front of pet, at mouth height
// Food drops in front (not directly at mouth)
const foodSize = 10 * pixelSize;
const frontOffsetX = isFacingRight.value ? -foodSize - 5 : width + 5; // In front of pet
const mouthY = 8.5; // Mouth is at row 8-9
// Set horizontal position (in front of pet)
foodX.value = petX.value + frontOffsetX;
foodY.value = 0; // Start from top of screen
// Calculate target Y (at mouth level)
const targetY = petY.value + (mouthY * pixelSize) - (foodSize / 2);
// Animate falling to front of pet
const duration = 800;
const startTime = performance.now();
await new Promise(resolve => {
function animateFall(time) {
const elapsed = time - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease out for smoother landing
const eased = 1 - Math.pow(1 - progress, 3);
foodY.value = eased * targetY;
if (progress < 1) {
requestAnimationFrame(animateFall);
} else {
resolve();
}
}
requestAnimationFrame(animateFall);
});
// Food is now at mouth level in front of pet
// Pet takes 3 bites
for (let i = 0; i < 3; i++) {
// Wait a moment before opening mouth
await new Promise(r => setTimeout(r, 200));
// Open mouth (bite!)
isMouthOpen.value = true;
await new Promise(r => setTimeout(r, 250));
// Close mouth and reduce food
isMouthOpen.value = false;
foodStage.value = i + 1; // 0->1 (2/3), 1->2 (1/3), 2->3 (Gone)
// If last bite, hide food
if (foodStage.value >= 3) {
foodVisible.value = false;
}
await new Promise(r => setTimeout(r, 200));
}
// Finish eating
foodVisible.value = false;
emit('update:state', 'idle');
}
// Initialize position
let resizeObserver;
function initPosition() {
if (containerRef.value) {
const cw = containerRef.value.clientWidth;
const ch = containerRef.value.clientHeight;
containerWidth.value = cw;
containerHeight.value = ch;
if (cw > 0 && ch > 0) {
petX.value = Math.floor(cw / 2 - width / 2);
petY.value = Math.floor(ch / 2 - height / 2);
updateHeadIconsPosition();
return true;
}
}
return false;
}
onMounted(async () => {
await nextTick(); // Wait for DOM
// Polling init if 0 size
if (!initPosition()) {
const initInterval = setInterval(() => {
if (initPosition()) {
clearInterval(initInterval);
}
}, 100);
// Stop polling after 5 seconds
setTimeout(() => clearInterval(initInterval), 5000);
}
if (containerRef.value) {
resizeObserver = new ResizeObserver(() => {
initPosition(); // Re-check size on resize
});
resizeObserver.observe(containerRef.value);
}
intervalId = setInterval(moveRandomly, 600);
});
onUnmounted(() => {
clearInterval(intervalId);
if (resizeObserver) resizeObserver.disconnect();
});
// Watch state changes to update icon position immediately
watch(() => props.state, (newState) => {
updateHeadIconsPosition();
if (newState === 'eating') {
startFeeding();
} else {
// Reset feeding state if interrupted
isMouthOpen.value = false;
foodVisible.value = false;
}
});
// Shake head function (refuse to eat)
async function shakeHead() {
const originalDirection = isFacingRight.value;
// 開始搖頭,暫停其他動畫
isShakingHead.value = true;
// 搖頭三次(慢慢地,像生病一樣虛弱)
for (let i = 0; i < 3; i++) {
// Turn opposite (slowly)
isFacingRight.value = !isFacingRight.value;
await new Promise(r => setTimeout(r, 400)); // 放慢速度
// Turn back (slowly)
isFacingRight.value = !isFacingRight.value;
await new Promise(r => setTimeout(r, 400)); // 放慢速度
}
// Restore original direction
isFacingRight.value = originalDirection;
// 搖頭結束,恢復動畫
isShakingHead.value = false;
}
// Expose shakeHead function to parent component
defineExpose({
shakeHead
});
</script>
<style scoped>
.pet-game-container {
position: relative;
width: 100%;
height: 100%;
}
.debug-overlay {
position: absolute;
bottom: 2px;
right: 2px;
font-size: 8px;
color: rgba(0,0,0,0.5);
pointer-events: none;
background: rgba(255,255,255,0.5);
padding: 2px;
}
.pet-root {
position: absolute;
transform-origin: center bottom;
z-index: 5;
}
.pet-inner {
position: relative;
transform-origin: center bottom;
width: 100%;
height: 100%;
}
.pet-inner.face-right {
transform: scaleX(1);
}
.pet-inner.face-left {
transform: scaleX(-1);
}
.pet-pixel {
position: absolute;
}
.food-item {
position: absolute;
z-index: 10;
}
/* Animations */
.pet-root.state-idle {
animation: pet-breathe-idle 2s ease-in-out infinite, pet-head-tilt 6s ease-in-out infinite;
}
.pet-root.state-eating {
animation: none; /* No body movement when eating, only mouth opens/closes */
}
/* Stop all animations when shaking head */
.pet-root.shaking-head {
animation: none !important;
}
.pet-root.shaking-head .tail-pixel,
.pet-root.shaking-head .leg-front,
.pet-root.shaking-head .leg-back,
.pet-root.shaking-head .ear-pixel,
.pet-root.shaking-head .blush-dot {
animation: none !important;
}
.pet-root.state-sleep {
animation: pet-breathe-sleep 4s ease-in-out infinite;
}
.pet-root.state-sick {
animation: pet-breathe-idle 2s ease-in-out infinite, pet-sick-shake 0.6s ease-in-out infinite;
filter: brightness(0.8) saturate(0.9);
}
@keyframes pet-breathe-idle {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.03); }
}
@keyframes pet-breathe-sleep {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.015); }
}
@keyframes pet-head-tilt {
0%, 100% { transform: rotate(0deg) scale(1); }
25% { transform: rotate(-2deg) scale(1.02); }
75% { transform: rotate(2deg) scale(1.02); }
}
@keyframes pet-sick-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-2px); }
75% { transform: translateX(2px); }
}
/* Tail */
.tail-pixel { transform-origin: center center; }
.pet-root.state-idle .tail-pixel { animation: tail-wag-idle 0.5s ease-in-out infinite; }
.pet-root.state-sleep .tail-pixel { animation: tail-wag-sleep 1.6s ease-in-out infinite; }
.pet-root.state-sick .tail-pixel { animation: tail-wag-sick 0.7s ease-in-out infinite; }
@keyframes tail-wag-idle {
0% { transform: translateX(0px); }
50% { transform: translateX(1px); }
100% { transform: translateX(0px); }
}
@keyframes tail-wag-sleep {
0% { transform: translateX(0px); }
50% { transform: translateX(0.5px); }
100% { transform: translateX(0px); }
}
@keyframes tail-wag-sick {
0% { transform: translateX(0px); }
50% { transform: translateX(0.8px); }
100% { transform: translateX(0px); }
}
/* Legs */
.leg-front, .leg-back { transform-origin: center center; }
.pet-root.state-idle .leg-front { animation: leg-front-step 0.6s ease-in-out infinite; }
.pet-root.state-idle .leg-back { animation: leg-back-step 0.6s ease-in-out infinite; }
.pet-root.state-sick .leg-front, .pet-root.state-sick .leg-back { animation: leg-sick-step 0.8s ease-in-out infinite; }
@keyframes leg-front-step {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-1px); }
}
@keyframes leg-back-step {
0%, 100% { transform: translateY(-1px); }
50% { transform: translateY(0); }
}
@keyframes leg-sick-step {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-0.5px); }
}
/* Ears */
.ear-pixel { transform-origin: center center; }
.pet-root.state-idle .ear-pixel { animation: ear-twitch 3.2s ease-in-out infinite; }
.pet-root.state-sick .ear-pixel { animation: ear-twitch 4s ease-in-out infinite; }
@keyframes ear-twitch {
0%, 90%, 100% { transform: translateY(0); }
92% { transform: translateY(-1px); }
96% { transform: translateY(0); }
}
/* Blush */
.blush-dot { transform-origin: center center; }
.pet-root.state-idle .blush-dot, .pet-root.state-sick .blush-dot { animation: blush-pulse 2s ease-in-out infinite; }
.pet-root.state-sleep .blush-dot { animation: blush-pulse 3s ease-in-out infinite; }
@keyframes blush-pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
/* Sleep ZZZ */
.sleep-zzz {
position: absolute;
font-weight: bold;
color: #5a7a4a;
pointer-events: none;
z-index: 10;
}
.sleep-zzz span {
position: absolute;
font-size: 10px;
opacity: 0;
animation: zzz-float 3s ease-in-out infinite;
}
.sleep-zzz .z1 { left: 0; animation-delay: 0s; }
.sleep-zzz .z2 { left: 8px; animation-delay: 0.8s; }
.sleep-zzz .z3 { left: 15px; animation-delay: 1.6s; }
@keyframes zzz-float {
0% { opacity: 0; transform: translateY(0) scale(0.7); }
20% { opacity: 1; transform: translateY(-4px) scale(0.9); }
80% { opacity: 1; transform: translateY(-16px) scale(1.05); }
100% { opacity: 0; transform: translateY(-24px) scale(1.1); }
}
/* Sick Icon */
.sick-icon {
position: absolute;
font-size: 14px;
pointer-events: none;
z-index: 10;
animation: sick-icon-pulse 1.2s ease-in-out infinite;
}
@keyframes sick-icon-pulse {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
/* Tombstone */
.tombstone {
position: absolute;
left: 50%;
top: 52%;
transform: translate(-50%, -50%);
width: 48px;
height: 56px;
border-radius: 16px 16px 6px 6px;
background: #9ba7a0;
border: 2px solid #5e6861;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.2), 0 4px 4px rgba(0, 0, 0, 0.35);
z-index: 10;
animation: tomb-float 3s ease-in-out infinite;
}
.tombstone::before {
content: "";
position: absolute;
left: 8px; right: 8px; top: 16px;
height: 2px;
background: #5e6861;
}
.tombstone::after {
content: "RIP";
position: absolute;
top: 22px; left: 50%;
transform: translateX(-50%);
font-size: 8px;
letter-spacing: 1px;
color: #39413c;
}
@keyframes tomb-float {
0%, 100% { transform: translate(-50%, -50%) translateY(0); }
50% { transform: translate(-50%, -50%) translateY(-4px); }
}
</style>