2025-11-20 07:01:22 +00:00
|
|
|
<template>
|
2025-11-20 09:15:38 +00:00
|
|
|
<div class="pet-game-wrapper">
|
|
|
|
|
<!-- Top Menu -->
|
|
|
|
|
<TopMenu
|
|
|
|
|
:disabled="stage === 'egg'"
|
|
|
|
|
@stats="$emit('action', 'stats')"
|
|
|
|
|
@feed="$emit('action', 'feed')"
|
|
|
|
|
@play="$emit('action', 'play')"
|
|
|
|
|
@sleep="$emit('action', 'sleep')"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<!-- Stats Dashboard (Toggelable) -->
|
|
|
|
|
<StatsBar
|
|
|
|
|
v-if="showStats"
|
|
|
|
|
:hunger="stats?.hunger || 100"
|
|
|
|
|
:happiness="stats?.happiness || 100"
|
|
|
|
|
:health="stats?.health || 100"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<!-- Game Area (Center) -->
|
|
|
|
|
<div class="pet-game-container" ref="containerRef">
|
|
|
|
|
<!-- 關燈黑色遮罩 -->
|
|
|
|
|
<div
|
|
|
|
|
v-if="state === 'sleep'"
|
|
|
|
|
class="dark-overlay"
|
|
|
|
|
></div>
|
|
|
|
|
|
|
|
|
|
<!-- 寵物本體 -->
|
|
|
|
|
<div
|
|
|
|
|
class="pet-root"
|
|
|
|
|
ref="petRef"
|
|
|
|
|
:style="{
|
|
|
|
|
left: petX + 'px',
|
|
|
|
|
top: petY + 'px',
|
|
|
|
|
width: width + 'px',
|
|
|
|
|
height: height + 'px',
|
|
|
|
|
display: (state === 'dead' || state === 'sleep') ? 'none' : 'block',
|
|
|
|
|
zIndex: 10
|
|
|
|
|
}"
|
|
|
|
|
:class="['state-' + state, 'stage-' + stage, { '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>
|
2025-11-20 07:01:22 +00:00
|
|
|
</div>
|
|
|
|
|
|
2025-11-20 09:15:38 +00:00
|
|
|
<!-- 食物 -->
|
|
|
|
|
<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>
|
2025-11-20 07:01:22 +00:00
|
|
|
|
2025-11-20 09:15:38 +00:00
|
|
|
<!-- 睡覺 ZZZ -->
|
|
|
|
|
<div
|
|
|
|
|
class="sleep-zzz"
|
|
|
|
|
:class="{ 'dark-mode': state === 'sleep' }"
|
|
|
|
|
:style="iconStyle"
|
|
|
|
|
v-show="state === 'sleep'"
|
|
|
|
|
>
|
|
|
|
|
<span class="z1">Z</span>
|
|
|
|
|
<span class="z2">Z</span>
|
|
|
|
|
<span class="z3">Z</span>
|
|
|
|
|
</div>
|
2025-11-20 07:01:22 +00:00
|
|
|
|
2025-11-20 09:15:38 +00:00
|
|
|
<!-- 生病骷髏頭 -->
|
|
|
|
|
<div class="sick-icon" :style="iconStyle" v-show="state === 'sick'">💀</div>
|
|
|
|
|
|
|
|
|
|
<!-- 死亡墓碑 -->
|
|
|
|
|
<div class="tombstone" v-show="state === 'dead'"></div>
|
|
|
|
|
|
|
|
|
|
<!-- 便便 (Poop) - Scattered on sides -->
|
|
|
|
|
<div
|
|
|
|
|
v-for="i in Math.min(stats?.poopCount || 0, 4)"
|
|
|
|
|
:key="'poop-' + i"
|
|
|
|
|
class="poop"
|
|
|
|
|
:class="{ 'flushing': isCleaning }"
|
|
|
|
|
:style="getPoopPosition(i)"
|
|
|
|
|
>
|
|
|
|
|
<div class="poop-sprite"></div>
|
|
|
|
|
<div class="poop-stink">
|
|
|
|
|
<span class="stink-line s1"></span>
|
|
|
|
|
<span class="stink-line s2"></span>
|
|
|
|
|
<span class="stink-line s3"></span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-11-20 07:01:22 +00:00
|
|
|
|
2025-11-20 09:15:38 +00:00
|
|
|
<!-- Debug Overlay (Hidden) -->
|
|
|
|
|
<div class="debug-overlay" v-if="false">
|
|
|
|
|
{{ containerWidth }}x{{ containerHeight }} | {{ Math.round(petX) }},{{ Math.round(petY) }} | {{ state }}
|
|
|
|
|
</div>
|
2025-11-20 07:01:22 +00:00
|
|
|
|
2025-11-20 09:15:38 +00:00
|
|
|
<!-- Flush Animation - Single wave covering all poop areas -->
|
|
|
|
|
<div
|
|
|
|
|
v-if="isCleaning && (stats?.poopCount || 0) > 0"
|
|
|
|
|
class="flush-wave"
|
|
|
|
|
:style="getFlushAreaStyle()"
|
|
|
|
|
>
|
|
|
|
|
<div class="wave-drop"></div>
|
|
|
|
|
</div>
|
2025-11-20 07:01:22 +00:00
|
|
|
</div>
|
2025-11-20 09:15:38 +00:00
|
|
|
|
|
|
|
|
<!-- Action Menu (Bottom) -->
|
|
|
|
|
<ActionMenu
|
|
|
|
|
:disabled="stage === 'egg'"
|
|
|
|
|
:poopCount="stats?.poopCount || 0"
|
|
|
|
|
:health="stats?.health || 100"
|
|
|
|
|
:isSick="state === 'sick'"
|
|
|
|
|
@clean="$emit('action', 'clean')"
|
|
|
|
|
@medicine="$emit('action', 'medicine')"
|
|
|
|
|
@training="$emit('action', 'training')"
|
|
|
|
|
@info="$emit('action', 'info')"
|
|
|
|
|
/>
|
2025-11-20 07:01:22 +00:00
|
|
|
</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';
|
2025-11-20 09:15:38 +00:00
|
|
|
import StatsBar from './StatsBar.vue';
|
|
|
|
|
import ActionMenu from './ActionMenu.vue';
|
|
|
|
|
import TopMenu from './TopMenu.vue';
|
2025-11-20 07:01:22 +00:00
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
|
state: {
|
|
|
|
|
type: String,
|
|
|
|
|
default: 'idle'
|
|
|
|
|
},
|
|
|
|
|
stage: {
|
|
|
|
|
type: String,
|
|
|
|
|
default: 'adult' // 'egg' or 'adult'
|
2025-11-20 09:15:38 +00:00
|
|
|
},
|
|
|
|
|
stats: {
|
|
|
|
|
type: Object,
|
|
|
|
|
default: () => ({ hunger: 100, happiness: 100, health: 100, poopCount: 0 })
|
|
|
|
|
},
|
|
|
|
|
showStats: {
|
|
|
|
|
type: Boolean,
|
|
|
|
|
default: false
|
|
|
|
|
},
|
|
|
|
|
isCleaning: {
|
|
|
|
|
type: Boolean,
|
|
|
|
|
default: false
|
2025-11-20 07:01:22 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-20 09:15:38 +00:00
|
|
|
const emit = defineEmits(['update:state', 'action']);
|
|
|
|
|
|
|
|
|
|
// Stats visibility toggle (removed local ref, using prop instead)
|
|
|
|
|
|
|
|
|
|
// Poop position calculator (all on left side, strictly in game area)
|
|
|
|
|
function getPoopPosition(index) {
|
|
|
|
|
const positions = [
|
|
|
|
|
{ left: '10px', bottom: '15px' }, // Bottom left
|
|
|
|
|
{ left: '45px', bottom: '15px' }, // Bottom left-center
|
|
|
|
|
{ left: '10px', bottom: '50px' }, // Mid left
|
|
|
|
|
{ left: '45px', bottom: '50px' } // Mid left-center
|
|
|
|
|
];
|
|
|
|
|
return positions[index - 1] || positions[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Poop area boundaries (for collision detection)
|
|
|
|
|
// Each poop area is 32px x 32px
|
|
|
|
|
const POOP_AREA_SIZE = 32;
|
|
|
|
|
function getPoopAreas() {
|
|
|
|
|
const poopCount = props.stats?.poopCount || 0;
|
|
|
|
|
if (poopCount === 0) return []; // No poop, return empty array
|
|
|
|
|
|
|
|
|
|
const positions = [
|
|
|
|
|
{ left: 10, bottom: 15 }, // Index 1: Bottom left
|
|
|
|
|
{ left: 45, bottom: 15 }, // Index 2: Bottom left-center
|
|
|
|
|
{ left: 10, bottom: 50 }, // Index 3: Mid left
|
|
|
|
|
{ left: 45, bottom: 50 } // Index 4: Mid left-center
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (!containerRef.value) return [];
|
|
|
|
|
const ch = containerRef.value.clientHeight;
|
|
|
|
|
|
|
|
|
|
// Only return areas that actually have poop (based on poopCount)
|
|
|
|
|
return positions.slice(0, Math.min(poopCount, 4)).map(pos => ({
|
|
|
|
|
left: pos.left,
|
|
|
|
|
right: pos.left + POOP_AREA_SIZE,
|
|
|
|
|
top: ch - pos.bottom - POOP_AREA_SIZE,
|
|
|
|
|
bottom: ch - pos.bottom
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if a position overlaps with any poop area (only if there's actual poop)
|
|
|
|
|
function isInPoopArea(x, y, w, h) {
|
|
|
|
|
const areas = getPoopAreas();
|
|
|
|
|
if (areas.length === 0) return false; // No poop, can move anywhere
|
|
|
|
|
return areas.some(area => {
|
|
|
|
|
return !(x + w < area.left || x > area.right || y + h < area.top || y > area.bottom);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get style for flush area covering all poop positions
|
|
|
|
|
function getFlushAreaStyle() {
|
|
|
|
|
if (!containerRef.value) return {};
|
|
|
|
|
const ch = containerRef.value.clientHeight;
|
|
|
|
|
|
|
|
|
|
// Calculate bounding box for all poop areas
|
|
|
|
|
// Poop positions: left 10px, 45px; bottom 15px, 50px
|
|
|
|
|
const minLeft = 10;
|
|
|
|
|
const maxRight = 45 + POOP_AREA_SIZE; // 45 + 32 = 77
|
|
|
|
|
const minTop = ch - 50 - POOP_AREA_SIZE; // Top of highest poop (bottom: 50)
|
|
|
|
|
const maxBottom = ch - 15; // Bottom of lowest poop (bottom: 15)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
left: minLeft + 'px',
|
|
|
|
|
top: '0px', // Start from top of screen
|
|
|
|
|
width: (maxRight - minLeft) + 'px',
|
|
|
|
|
height: maxBottom + 'px'
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-11-20 07:01:22 +00:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
2025-11-20 09:15:38 +00:00
|
|
|
// Animation State
|
|
|
|
|
const isBlinking = ref(false);
|
|
|
|
|
|
2025-11-20 07:01:22 +00:00
|
|
|
const currentPixels = computed(() => {
|
2025-11-20 09:15:38 +00:00
|
|
|
// Priority: Egg > Blink > Mouth Open > Normal
|
2025-11-20 07:01:22 +00:00
|
|
|
if (props.stage === 'egg') {
|
|
|
|
|
return generatePixels(CURRENT_PRESET.eggSprite, CURRENT_PRESET.eggPalette);
|
|
|
|
|
}
|
2025-11-20 09:15:38 +00:00
|
|
|
|
|
|
|
|
// Blink overrides mouth state (when blinking, always show closed eyes)
|
|
|
|
|
if (isBlinking.value && CURRENT_PRESET.spriteEyesClosed) {
|
|
|
|
|
return generatePixels(CURRENT_PRESET.spriteEyesClosed);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-20 07:01:22 +00:00
|
|
|
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));
|
|
|
|
|
|
2025-11-20 09:15:38 +00:00
|
|
|
// Check if new position would overlap with poop areas (only if there's actual poop)
|
|
|
|
|
if (isInPoopArea(newX, newY, width, height)) {
|
|
|
|
|
// If overlapping, try to move away from poop areas
|
|
|
|
|
// Prefer moving right or up to avoid poop areas on the left
|
|
|
|
|
if (newX < 80) {
|
|
|
|
|
newX = Math.max(80, newX); // Move right of poop areas
|
|
|
|
|
}
|
|
|
|
|
// If still overlapping, try moving up
|
|
|
|
|
if (isInPoopArea(newX, newY, width, height)) {
|
|
|
|
|
const areas = getPoopAreas();
|
|
|
|
|
if (areas.length > 0) {
|
|
|
|
|
const maxPoopBottom = Math.max(...areas.map(a => a.bottom));
|
|
|
|
|
newY = Math.max(minY, Math.min(newY, maxPoopBottom - height - 5));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-20 07:01:22 +00:00
|
|
|
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)
|
2025-11-20 09:15:38 +00:00
|
|
|
let targetFoodX = petX.value + frontOffsetX;
|
|
|
|
|
|
|
|
|
|
// Only avoid poop areas if there's actual poop
|
|
|
|
|
const areas = getPoopAreas();
|
|
|
|
|
if (areas.length > 0) {
|
|
|
|
|
const maxPoopRight = Math.max(...areas.map(a => a.right));
|
|
|
|
|
if (targetFoodX < maxPoopRight + 10) {
|
|
|
|
|
// Move food to the right of poop areas
|
|
|
|
|
targetFoodX = maxPoopRight + 10;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foodX.value = targetFoodX;
|
2025-11-20 07:01:22 +00:00
|
|
|
foodY.value = 0; // Start from top of screen
|
|
|
|
|
|
|
|
|
|
// Calculate target Y (at mouth level)
|
|
|
|
|
const targetY = petY.value + (mouthY * pixelSize) - (foodSize / 2);
|
|
|
|
|
|
2025-11-20 09:15:38 +00:00
|
|
|
// Only avoid poop areas vertically if there's actual poop
|
|
|
|
|
let safeTargetY = targetY;
|
|
|
|
|
if (areas.length > 0) {
|
|
|
|
|
const maxPoopBottom = Math.max(...areas.map(a => a.bottom));
|
|
|
|
|
safeTargetY = Math.min(targetY, maxPoopBottom - foodSize - 5);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-20 07:01:22 +00:00
|
|
|
// 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);
|
2025-11-20 09:15:38 +00:00
|
|
|
foodY.value = eased * safeTargetY;
|
2025-11-20 07:01:22 +00:00
|
|
|
|
|
|
|
|
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
|
2025-11-20 09:15:38 +00:00
|
|
|
let intervalId;
|
2025-11-20 07:01:22 +00:00
|
|
|
let resizeObserver;
|
2025-11-20 09:15:38 +00:00
|
|
|
let blinkTimeoutId;
|
2025-11-20 07:01:22 +00:00
|
|
|
|
|
|
|
|
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) {
|
2025-11-20 09:15:38 +00:00
|
|
|
let initialX = Math.floor(cw / 2 - width / 2);
|
|
|
|
|
let initialY = Math.floor(ch / 2 - height / 2);
|
|
|
|
|
|
|
|
|
|
// Only avoid poop areas if there's actual poop
|
|
|
|
|
if (isInPoopArea(initialX, initialY, width, height)) {
|
|
|
|
|
// Move to the right of poop areas
|
|
|
|
|
initialX = Math.max(80, initialX);
|
|
|
|
|
// If still overlapping, move up
|
|
|
|
|
if (isInPoopArea(initialX, initialY, width, height)) {
|
|
|
|
|
const areas = getPoopAreas();
|
|
|
|
|
if (areas.length > 0) {
|
|
|
|
|
const maxPoopBottom = Math.max(...areas.map(a => a.bottom));
|
|
|
|
|
initialY = Math.max(10, Math.min(initialY, maxPoopBottom - height - 5));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
petX.value = initialX;
|
|
|
|
|
petY.value = initialY;
|
2025-11-20 07:01:22 +00:00
|
|
|
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);
|
2025-11-20 09:15:38 +00:00
|
|
|
|
|
|
|
|
// Blink Animation Timer
|
|
|
|
|
function scheduleBlink() {
|
|
|
|
|
const nextBlinkDelay = 3000 + Math.random() * 2000; // 3-5 seconds
|
|
|
|
|
blinkTimeoutId = setTimeout(() => {
|
|
|
|
|
// Don't blink when eating, dead, or egg
|
|
|
|
|
if (props.state !== 'eating' && props.state !== 'dead' && props.stage !== 'egg') {
|
|
|
|
|
isBlinking.value = true;
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
isBlinking.value = false;
|
|
|
|
|
}, 150); // Blink duration: 150ms
|
|
|
|
|
}
|
|
|
|
|
scheduleBlink(); // Schedule next blink
|
|
|
|
|
}, nextBlinkDelay);
|
|
|
|
|
}
|
|
|
|
|
scheduleBlink(); // Start blinking
|
2025-11-20 07:01:22 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
clearInterval(intervalId);
|
|
|
|
|
if (resizeObserver) resizeObserver.disconnect();
|
2025-11-20 09:15:38 +00:00
|
|
|
if (blinkTimeoutId) clearTimeout(blinkTimeoutId);
|
2025-11-20 07:01:22 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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>
|
2025-11-20 09:15:38 +00:00
|
|
|
.pet-game-wrapper {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-20 07:01:22 +00:00
|
|
|
.pet-game-container {
|
|
|
|
|
position: relative;
|
2025-11-20 09:15:38 +00:00
|
|
|
flex: 1;
|
2025-11-20 07:01:22 +00:00
|
|
|
width: 100%;
|
2025-11-20 09:15:38 +00:00
|
|
|
overflow: hidden;
|
2025-11-20 07:01:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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;
|
2025-11-20 09:15:38 +00:00
|
|
|
z-index: 10; /* Higher than water and other elements */
|
2025-11-20 07:01:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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; }
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-20 09:15:38 +00:00
|
|
|
/* Dark Overlay (關燈效果) */
|
|
|
|
|
.dark-overlay {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
background: #000;
|
|
|
|
|
z-index: 7; /* Above poop (5), Below ZZZ (10) */
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-20 07:01:22 +00:00
|
|
|
/* Sleep ZZZ */
|
|
|
|
|
.sleep-zzz {
|
|
|
|
|
position: absolute;
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
color: #5a7a4a;
|
|
|
|
|
pointer-events: none;
|
2025-11-20 09:15:38 +00:00
|
|
|
z-index: 10; /* Above dark overlay */
|
|
|
|
|
}
|
|
|
|
|
.sleep-zzz.dark-mode {
|
|
|
|
|
color: #fff; /* 顏色顛倒:在黑色背景下變白色 */
|
|
|
|
|
text-shadow: 0 0 4px rgba(255, 255, 255, 0.8); /* 添加發光效果讓它更明顯 */
|
2025-11-20 07:01:22 +00:00
|
|
|
}
|
|
|
|
|
.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); }
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-20 09:15:38 +00:00
|
|
|
/* Tombstone (Dead State) */
|
2025-11-20 07:01:22 +00:00
|
|
|
.tombstone {
|
|
|
|
|
position: absolute;
|
|
|
|
|
left: 50%;
|
2025-11-20 09:15:38 +00:00
|
|
|
top: 50%;
|
2025-11-20 07:01:22 +00:00
|
|
|
transform: translate(-50%, -50%);
|
2025-11-20 09:15:38 +00:00
|
|
|
width: 30px;
|
|
|
|
|
height: 40px;
|
|
|
|
|
background: linear-gradient(to bottom, #888 0%, #555 100%);
|
|
|
|
|
border-radius: 10px 10px 0 0;
|
|
|
|
|
border: 2px solid #333;
|
2025-11-20 07:01:22 +00:00
|
|
|
}
|
2025-11-20 09:15:38 +00:00
|
|
|
|
2025-11-20 07:01:22 +00:00
|
|
|
.tombstone::before {
|
2025-11-20 09:15:38 +00:00
|
|
|
content: 'RIP';
|
2025-11-20 07:01:22 +00:00
|
|
|
position: absolute;
|
2025-11-20 09:15:38 +00:00
|
|
|
top: 50%;
|
|
|
|
|
left: 50%;
|
|
|
|
|
transform: translate(-50%, -50%);
|
2025-11-20 07:01:22 +00:00
|
|
|
font-size: 8px;
|
2025-11-20 09:15:38 +00:00
|
|
|
font-weight: bold;
|
|
|
|
|
color: white;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Poop Sprite (Larger & More Detailed) */
|
|
|
|
|
.poop {
|
|
|
|
|
position: absolute;
|
|
|
|
|
width: 32px;
|
|
|
|
|
height: 32px;
|
|
|
|
|
z-index: 5;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.poop-sprite {
|
|
|
|
|
position: relative;
|
|
|
|
|
width: 3px;
|
|
|
|
|
height: 3px;
|
|
|
|
|
background: #3d2817;
|
|
|
|
|
top: 50%;
|
|
|
|
|
left: 50%;
|
|
|
|
|
transform: translate(-50%, -50%);
|
|
|
|
|
box-shadow:
|
|
|
|
|
/* Top point */
|
|
|
|
|
0px -12px 0 #3d2817,
|
|
|
|
|
/* Row 2 - narrow */
|
|
|
|
|
-3px -9px 0 #3d2817, 0px -9px 0 #5a4028, 3px -9px 0 #3d2817,
|
|
|
|
|
/* Row 3 */
|
|
|
|
|
-6px -6px 0 #3d2817, -3px -6px 0 #5a4028, 0px -6px 0 #6b4e38,
|
|
|
|
|
3px -6px 0 #5a4028, 6px -6px 0 #3d2817,
|
|
|
|
|
/* Row 4 - eyes */
|
|
|
|
|
-6px -3px 0 #3d2817, -3px -3px 0 #ffffff, 0px -3px 0 #6b4e38,
|
|
|
|
|
3px -3px 0 #ffffff, 6px -3px 0 #3d2817,
|
|
|
|
|
/* Row 5 - middle */
|
|
|
|
|
-9px 0px 0 #3d2817, -6px 0px 0 #5a4028, -3px 0px 0 #6b4e38, 0px 0px 0 #7d5a3a,
|
|
|
|
|
3px 0px 0 #6b4e38, 6px 0px 0 #5a4028, 9px 0px 0 #3d2817,
|
|
|
|
|
/* Row 6 */
|
|
|
|
|
-9px 3px 0 #3d2817, -6px 3px 0 #5a4028, -3px 3px 0 #6b4e38, 0px 3px 0 #7d5a3a,
|
|
|
|
|
3px 3px 0 #6b4e38, 6px 3px 0 #5a4028, 9px 3px 0 #3d2817,
|
|
|
|
|
/* Row 7 */
|
|
|
|
|
-12px 6px 0 #3d2817, -9px 6px 0 #3d2817, -6px 6px 0 #5a4028, -3px 6px 0 #6b4e38,
|
|
|
|
|
0px 6px 0 #7d5a3a, 3px 6px 0 #6b4e38, 6px 6px 0 #5a4028, 9px 6px 0 #3d2817, 12px 6px 0 #3d2817,
|
|
|
|
|
/* Row 8 - wider */
|
|
|
|
|
-12px 9px 0 #3d2817, -9px 9px 0 #5a4028, -6px 9px 0 #6b4e38, -3px 9px 0 #7d5a3a,
|
|
|
|
|
0px 9px 0 #7d5a3a, 3px 9px 0 #7d5a3a, 6px 9px 0 #6b4e38, 9px 9px 0 #5a4028, 12px 9px 0 #3d2817,
|
|
|
|
|
/* Bottom row */
|
|
|
|
|
-9px 12px 0 #3d2817, -6px 12px 0 #5a4028, -3px 12px 0 #6b4e38,
|
|
|
|
|
0px 12px 0 #6b4e38, 3px 12px 0 #6b4e38, 6px 12px 0 #5a4028, 9px 12px 0 #3d2817;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Stink Animation (3 Fingers Style - Wavy) */
|
|
|
|
|
.poop-stink {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: -25px;
|
|
|
|
|
left: 50%;
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
width: 40px;
|
|
|
|
|
height: 30px;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stink-line {
|
|
|
|
|
position: absolute;
|
|
|
|
|
bottom: 0;
|
|
|
|
|
left: 50%;
|
|
|
|
|
width: 2px;
|
|
|
|
|
height: 2px;
|
|
|
|
|
background: transparent;
|
|
|
|
|
/* Pixel art vertical wave pattern */
|
|
|
|
|
box-shadow:
|
|
|
|
|
0px 0px 0 #555,
|
|
|
|
|
1px -2px 0 #555,
|
|
|
|
|
1px -4px 0 #555,
|
|
|
|
|
0px -6px 0 #555,
|
|
|
|
|
-1px -8px 0 #555,
|
|
|
|
|
-1px -10px 0 #555,
|
|
|
|
|
0px -12px 0 #555;
|
|
|
|
|
opacity: 0;
|
|
|
|
|
transform-origin: bottom center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Left Finger */
|
|
|
|
|
.stink-line.s1 {
|
|
|
|
|
animation: stink-finger-1 2s ease-in-out infinite;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Middle Finger */
|
|
|
|
|
.stink-line.s2 {
|
|
|
|
|
/* Slightly taller wave for middle */
|
|
|
|
|
box-shadow:
|
|
|
|
|
0px 0px 0 #555,
|
|
|
|
|
1px -2px 0 #555,
|
|
|
|
|
1px -4px 0 #555,
|
|
|
|
|
0px -6px 0 #555,
|
|
|
|
|
-1px -8px 0 #555,
|
|
|
|
|
-1px -10px 0 #555,
|
|
|
|
|
0px -12px 0 #555,
|
|
|
|
|
1px -14px 0 #555;
|
|
|
|
|
animation: stink-finger-2 2s ease-in-out infinite;
|
|
|
|
|
animation-delay: 0.2s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Right Finger */
|
|
|
|
|
.stink-line.s3 {
|
|
|
|
|
animation: stink-finger-3 2s ease-in-out infinite;
|
|
|
|
|
animation-delay: 0.4s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes stink-finger-1 {
|
|
|
|
|
0% { opacity: 0; transform: translateX(-50%) rotate(-25deg) scaleY(0.5); }
|
|
|
|
|
20% { opacity: 0.8; transform: translateX(-50%) rotate(-25deg) scaleY(1) translateY(-5px); }
|
|
|
|
|
80% { opacity: 0.4; transform: translateX(-50%) rotate(-35deg) scaleY(1) translateY(-15px); }
|
|
|
|
|
100% { opacity: 0; transform: translateX(-50%) rotate(-40deg) scaleY(1.2) translateY(-20px); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes stink-finger-2 {
|
|
|
|
|
0% { opacity: 0; transform: translateX(-50%) rotate(0deg) scaleY(0.5); }
|
|
|
|
|
20% { opacity: 0.8; transform: translateX(-50%) rotate(0deg) scaleY(1) translateY(-6px); }
|
|
|
|
|
80% { opacity: 0.4; transform: translateX(-50%) rotate(0deg) scaleY(1) translateY(-18px); }
|
|
|
|
|
100% { opacity: 0; transform: translateX(-50%) rotate(0deg) scaleY(1.2) translateY(-24px); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes stink-finger-3 {
|
|
|
|
|
0% { opacity: 0; transform: translateX(-50%) rotate(25deg) scaleY(0.5); }
|
|
|
|
|
20% { opacity: 0.8; transform: translateX(-50%) rotate(25deg) scaleY(1) translateY(-5px); }
|
|
|
|
|
80% { opacity: 0.4; transform: translateX(-50%) rotate(35deg) scaleY(1) translateY(-15px); }
|
|
|
|
|
100% { opacity: 0; transform: translateX(-50%) rotate(40deg) scaleY(1.2) translateY(-20px); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Poop Flush Animation */
|
|
|
|
|
.poop.flushing .poop-stink {
|
|
|
|
|
display: none; /* Hide stink immediately when flushing */
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.poop.flushing .poop-sprite {
|
|
|
|
|
animation: poop-flush 1.5s ease-in-out forwards;
|
|
|
|
|
animation-delay: 0.5s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes poop-flush {
|
|
|
|
|
0% {
|
|
|
|
|
transform: translate(-50%, -50%) rotate(0deg) scale(1);
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
100% {
|
|
|
|
|
transform: translate(-50%, -50%) rotate(720deg) scale(0);
|
|
|
|
|
opacity: 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Flush Animation (Pixel Style - Top & Bottom) */
|
|
|
|
|
/* Flush Wave - Single wave covering all poop areas, drops from top */
|
|
|
|
|
.flush-wave {
|
|
|
|
|
position: absolute;
|
|
|
|
|
z-index: 6; /* Above poop (5), Below pet (10) */
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.wave-drop {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: -100%;
|
|
|
|
|
left: 0;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
background: #40a4ff;
|
|
|
|
|
animation: flush-drop 1.5s ease-out forwards;
|
|
|
|
|
/* Pixel pattern */
|
|
|
|
|
background-image:
|
|
|
|
|
linear-gradient(45deg, #60b4ff 25%, transparent 25%, transparent 75%, #60b4ff 75%, #60b4ff),
|
|
|
|
|
linear-gradient(45deg, #60b4ff 25%, transparent 25%, transparent 75%, #60b4ff 75%, #60b4ff);
|
|
|
|
|
background-size: 8px 8px;
|
|
|
|
|
background-position: 0 0, 4px 4px;
|
|
|
|
|
box-shadow:
|
|
|
|
|
inset 0 2px 0 #fff,
|
|
|
|
|
inset 0 -2px 0 #2a8fdd,
|
|
|
|
|
0 0 4px rgba(64, 164, 255, 0.5);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes flush-drop {
|
|
|
|
|
0% {
|
|
|
|
|
top: -100%;
|
|
|
|
|
opacity: 0.8;
|
|
|
|
|
}
|
|
|
|
|
20% {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
100% {
|
|
|
|
|
top: 0;
|
|
|
|
|
opacity: 0.9;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Egg breathe animation */
|
|
|
|
|
.pet-root.stage-egg .pet-inner {
|
|
|
|
|
animation: egg-breathe 2s ease-in-out infinite;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes egg-breathe {
|
|
|
|
|
0%, 100% { transform: scale(1); }
|
|
|
|
|
50% { transform: scale(1.03); }
|
2025-11-20 07:01:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes tomb-float {
|
|
|
|
|
0%, 100% { transform: translate(-50%, -50%) translateY(0); }
|
|
|
|
|
50% { transform: translate(-50%, -50%) translateY(-4px); }
|
|
|
|
|
}
|
|
|
|
|
</style>
|