feat: add func

This commit is contained in:
王性驊 2025-11-20 17:15:38 +08:00
parent d0e1bb91de
commit c3d07770be
7 changed files with 1366 additions and 150 deletions

View File

@ -4,43 +4,69 @@ import DeviceShell from './components/DeviceShell.vue';
import DeviceScreen from './components/DeviceScreen.vue';
import PetGame from './components/PetGame.vue';
import Menu from './components/Menu.vue';
import { usePetSystem } from './composables/usePetSystem';
const currentScreen = ref('game');
const currentState = ref('idle');
const currentStage = ref('adult'); // 'egg' or 'adult'
const petGameRef = ref(null);
const showStats = ref(false); // Stats visibility
// State Management
function setPetState(state) {
currentState.value = state;
}
// Initialize Pet System
const {
stage,
state,
stats,
feed,
play,
sleep,
clean,
isCleaning,
hatchEgg,
reset
} = usePetSystem();
function handleStateUpdate(newState) {
currentState.value = newState;
}
function toggleStage() {
currentStage.value = currentStage.value === 'egg' ? 'adult' : 'egg';
}
// Feeding Logic
const stateBeforeFeeding = ref('idle');
function startFeeding() {
if (currentState.value === 'sleep' || currentState.value === 'dead') return;
if (currentState.value === 'sick') {
if (petGameRef.value && petGameRef.value.shakeHead) {
petGameRef.value.shakeHead();
}
return;
}
if (currentState.value === 'idle') {
stateBeforeFeeding.value = currentState.value;
currentState.value = 'eating';
// Handle Action Menu Events
function handleAction(action) {
switch(action) {
case 'feed':
feed();
break;
case 'clean':
clean();
break;
case 'play':
play();
break;
case 'sleep':
sleep();
break;
case 'medicine':
// Heal the pet
if (state.value === 'sick') {
stats.value.health = 100;
state.value = 'idle';
}
break;
case 'stats':
// Toggle stats display
showStats.value = !showStats.value;
break;
case 'settings':
// Show reset options
if (stage.value === 'egg') {
hatchEgg();
} else {
reset();
}
break;
default:
console.log('Action not implemented:', action);
}
}
// Debug/Dev Controls
function setPetState(newState) {
state.value = newState;
}
</script>
<template>
@ -48,34 +74,83 @@ function startFeeding() {
<DeviceScreen>
<!-- Dynamic Component Switching -->
<PetGame
v-if="currentScreen === 'game'"
ref="petGameRef"
:state="currentState"
:stage="currentStage"
@update:state="handleStateUpdate"
:state="state"
:stage="stage"
:stats="stats"
:isCleaning="isCleaning"
:showStats="showStats"
@update:state="state = $event"
@action="handleAction"
/>
<Menu v-else />
</DeviceScreen>
</DeviceShell>
<!-- Controls (Outside Machine) -->
<div class="controls">
<div class="btn-group">
<button @click="setPetState('idle')" :disabled="currentStage === 'egg'">Idle</button>
<button @click="setPetState('sleep')" :disabled="currentStage === 'egg'">Sleep</button>
<button @click="setPetState('sick')" :disabled="currentStage === 'egg'">Sick</button>
<button @click="setPetState('dead')" :disabled="currentStage === 'egg'">Dead</button>
</div>
<div class="btn-group">
<button @click="startFeeding" :disabled="currentStage === 'egg'">Feed</button>
<button @click="toggleStage">{{ currentStage === 'egg' ? 'Hatch' : 'Reset to Egg' }}</button>
</div>
<div class="btn-group">
<button @click="currentScreen = 'game'" :disabled="currentStage === 'egg'">Game</button>
<button @click="currentScreen = 'menu'" :disabled="currentStage === 'egg'">Menu</button>
<!-- Debug Controls (Outside Device) -->
<div class="debug-controls">
<div class="control-section">
<h3>Debug Controls</h3>
<div class="btn-group">
<button @click="setPetState('idle')">Idle</button>
<button @click="setPetState('sleep')">Sleep</button>
<button @click="setPetState('sick')">Sick</button>
<button @click="setPetState('dead')">Dead</button>
</div>
<div class="btn-group">
<button @click="stats.poopCount = Math.min((stats.poopCount || 0) + 1, 4)">💩 Add Poop</button>
<button @click="stats.poopCount = 0">🧼 Clean All</button>
</div>
<div class="btn-group">
<button v-if="stage === 'egg'" @click="hatchEgg()">🥚 Hatch Egg</button>
<button v-else @click="reset()">🔄 Reset to Egg</button>
</div>
</div>
</div>
</template>
<style scoped>
.debug-controls {
margin-top: 20px;
padding: 15px;
background: #f0f0f0;
border-radius: 8px;
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
.control-section h3 {
margin: 0 0 10px 0;
font-size: 14px;
color: #666;
}
.btn-group {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.btn-group button {
flex: 1;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 12px;
}
.btn-group button:hover {
background: #e8e8e8;
}
.btn-group button:active {
background: #d0d0d0;
}
.controls {
display: flex;
flex-direction: column;

View File

@ -0,0 +1,126 @@
<template>
<div class="action-menu">
<button class="icon-btn icon-clean" @click="$emit('clean')" :disabled="disabled || poopCount === 0" title="Clean"></button>
<button class="icon-btn icon-medicine" @click="$emit('medicine')" :disabled="disabled || !isSick" title="Medicine"></button>
<button class="icon-btn icon-training" @click="$emit('training')" :disabled="disabled" title="Training"></button>
<button class="icon-btn icon-info" @click="$emit('info')" :disabled="disabled" title="Info"></button>
</div>
</template>
<script setup>
const props = defineProps({
disabled: {
type: Boolean,
default: false
},
poopCount: {
type: Number,
default: 0
},
health: {
type: Number,
default: 100
},
isSick: {
type: Boolean,
default: false
}
});
defineEmits(['clean', 'medicine', 'training', 'info']);
</script>
<style scoped>
.action-menu {
display: flex;
justify-content: space-around;
align-items: center;
padding: 6px 12px;
background: rgba(155, 188, 15, 0.05);
border-top: 2px solid rgba(0, 0, 0, 0.1);
}
.icon-btn {
width: 16px;
height: 16px;
border: none;
background: transparent;
cursor: pointer;
position: relative;
padding: 0;
}
.icon-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Clean Icon (Broom) */
.icon-clean::before {
content: '';
position: absolute;
width: 2px;
height: 2px;
background: #8B4513;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-shadow:
0px -6px 0 #8B4513, 0px -4px 0 #8B4513,
0px -2px 0 #8B4513, 0px 0px 0 #8B4513,
-4px 2px 0 #ffcc00, -2px 2px 0 #ffcc00, 0px 2px 0 #ffcc00, 2px 2px 0 #ffcc00, 4px 2px 0 #ffcc00,
-4px 4px 0 #ffcc00, -2px 4px 0 #ffcc00, 0px 4px 0 #ffcc00, 2px 4px 0 #ffcc00, 4px 4px 0 #ffcc00,
-2px 6px 0 #ffcc00, 0px 6px 0 #ffcc00, 2px 6px 0 #ffcc00;
}
/* Medicine Icon (Pill/Cross) */
.icon-medicine::before {
content: '';
position: absolute;
width: 2px;
height: 2px;
background: #ff4444;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-shadow:
0px -6px 0 #ff4444, 0px -4px 0 #ff4444,
-4px -2px 0 #ff4444, -2px -2px 0 #ff4444, 0px -2px 0 #ff4444, 2px -2px 0 #ff4444, 4px -2px 0 #ff4444,
0px 0px 0 #ff4444, 0px 2px 0 #ff4444,
0px 4px 0 #ff4444, 0px 6px 0 #ff4444;
}
/* Training Icon (Dumbbell) */
.icon-training::before {
content: '';
position: absolute;
width: 2px;
height: 2px;
background: #444;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-shadow:
-6px -2px 0 #444, -6px 0px 0 #444, -6px 2px 0 #444,
-4px -2px 0 #444, -4px 0px 0 #444, -4px 2px 0 #444,
-2px 0px 0 #444, 0px 0px 0 #444, 2px 0px 0 #444,
4px -2px 0 #444, 4px 0px 0 #444, 4px 2px 0 #444,
6px -2px 0 #444, 6px 0px 0 #444, 6px 2px 0 #444;
}
/* Info Icon (i) */
.icon-info::before {
content: '';
position: absolute;
width: 2px;
height: 2px;
background: #4444ff;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-shadow:
0px -6px 0 #4444ff,
0px -2px 0 #4444ff, 0px 0px 0 #4444ff,
0px 2px 0 #4444ff, 0px 4px 0 #4444ff, 0px 6px 0 #4444ff;
}
</style>

View File

@ -1,77 +1,146 @@
<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 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>
</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"
: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>
<!-- 生病骷髏頭 -->
<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>
<!-- Debug Overlay (Hidden) -->
<div class="debug-overlay" v-if="false">
{{ containerWidth }}x{{ containerHeight }} | {{ Math.round(petX) }},{{ Math.round(petY) }} | {{ state }}
</div>
<!-- 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>
</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>
<!-- 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')"
/>
</div>
</template>
@ -79,6 +148,9 @@
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { SPRITE_PRESETS } from '../data/petPresets.js';
import { FOOD_OPTIONS } from '../data/foodOptions.js';
import StatsBar from './StatsBar.vue';
import ActionMenu from './ActionMenu.vue';
import TopMenu from './TopMenu.vue';
const props = defineProps({
state: {
@ -88,10 +160,90 @@ const props = defineProps({
stage: {
type: String,
default: 'adult' // 'egg' or 'adult'
},
stats: {
type: Object,
default: () => ({ hunger: 100, happiness: 100, health: 100, poopCount: 0 })
},
showStats: {
type: Boolean,
default: false
},
isCleaning: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:state']);
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'
};
}
// Default food choice
const currentFood = 'banana'; // Can be: 'apple', 'banana', 'strawberry'
@ -165,10 +317,20 @@ const foodY = ref(0);
const foodStage = ref(0); // 0, 1, 2
const foodVisible = ref(false);
// Animation State
const isBlinking = ref(false);
const currentPixels = computed(() => {
// Priority: Egg > Blink > Mouth Open > Normal
if (props.stage === 'egg') {
return generatePixels(CURRENT_PRESET.eggSprite, CURRENT_PRESET.eggPalette);
}
// Blink overrides mouth state (when blinking, always show closed eyes)
if (isBlinking.value && CURRENT_PRESET.spriteEyesClosed) {
return generatePixels(CURRENT_PRESET.spriteEyesClosed);
}
return isMouthOpen.value
? generatePixels(CURRENT_PRESET.spriteMouthOpen)
: generatePixels(CURRENT_PRESET.sprite);
@ -263,6 +425,23 @@ function moveRandomly() {
newX = Math.max(minX, Math.min(maxX, newX));
newY = Math.max(minY, Math.min(maxY, newY));
// 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));
}
}
}
const dx = newX - petX.value;
if (dx > 0) isFacingRight.value = false;
else if (dx < 0) isFacingRight.value = true;
@ -286,12 +465,31 @@ async function startFeeding() {
const mouthY = 8.5; // Mouth is at row 8-9
// Set horizontal position (in front of pet)
foodX.value = petX.value + frontOffsetX;
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;
foodY.value = 0; // Start from top of screen
// Calculate target Y (at mouth level)
const targetY = petY.value + (mouthY * pixelSize) - (foodSize / 2);
// 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);
}
// Animate falling to front of pet
const duration = 800;
const startTime = performance.now();
@ -302,7 +500,7 @@ async function startFeeding() {
const progress = Math.min(elapsed / duration, 1);
// Ease out for smoother landing
const eased = 1 - Math.pow(1 - progress, 3);
foodY.value = eased * targetY;
foodY.value = eased * safeTargetY;
if (progress < 1) {
requestAnimationFrame(animateFall);
@ -341,7 +539,9 @@ async function startFeeding() {
}
// Initialize position
let intervalId;
let resizeObserver;
let blinkTimeoutId;
function initPosition() {
if (containerRef.value) {
@ -351,8 +551,25 @@ function initPosition() {
containerHeight.value = ch;
if (cw > 0 && ch > 0) {
petX.value = Math.floor(cw / 2 - width / 2);
petY.value = Math.floor(ch / 2 - height / 2);
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;
updateHeadIconsPosition();
return true;
}
@ -382,11 +599,28 @@ onMounted(async () => {
}
intervalId = setInterval(moveRandomly, 600);
// 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
});
onUnmounted(() => {
clearInterval(intervalId);
if (resizeObserver) resizeObserver.disconnect();
if (blinkTimeoutId) clearTimeout(blinkTimeoutId);
});
// Watch state changes to update icon position immediately
@ -433,12 +667,20 @@ defineExpose({
</script>
<style scoped>
.pet-game-container {
position: relative;
.pet-game-wrapper {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.pet-game-container {
position: relative;
flex: 1;
width: 100%;
overflow: hidden;
}
.debug-overlay {
position: absolute;
bottom: 2px;
@ -453,7 +695,7 @@ defineExpose({
.pet-root {
position: absolute;
transform-origin: center bottom;
z-index: 5;
z-index: 10; /* Higher than water and other elements */
}
.pet-inner {
@ -588,13 +830,29 @@ defineExpose({
50% { opacity: 1; }
}
/* 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;
}
/* Sleep ZZZ */
.sleep-zzz {
position: absolute;
font-weight: bold;
color: #5a7a4a;
pointer-events: none;
z-index: 10;
z-index: 10; /* Above dark overlay */
}
.sleep-zzz.dark-mode {
color: #fff; /* 顏色顛倒:在黑色背景下變白色 */
text-shadow: 0 0 4px rgba(255, 255, 255, 0.8); /* 添加發光效果讓它更明顯 */
}
.sleep-zzz span {
position: absolute;
@ -627,36 +885,225 @@ defineExpose({
50% { transform: translateY(-2px); }
}
/* Tombstone */
/* Tombstone (Dead State) */
.tombstone {
position: absolute;
left: 50%;
top: 52%;
top: 50%;
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;
width: 30px;
height: 40px;
background: linear-gradient(to bottom, #888 0%, #555 100%);
border-radius: 10px 10px 0 0;
border: 2px solid #333;
}
.tombstone::before {
content: "";
content: 'RIP';
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%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 8px;
letter-spacing: 1px;
color: #39413c;
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); }
}
@keyframes tomb-float {

238
src/components/StatsBar.vue Normal file
View File

@ -0,0 +1,238 @@
<template>
<div class="stats-bar">
<div class="stat-row">
<div class="stat-icon pixel-heart"></div>
<div class="pixel-bar">
<div
v-for="i in 10"
:key="'happy-' + i"
class="pixel-block"
:class="{
'filled': i <= Math.floor(happiness / 10),
'color-red': i <= Math.floor(happiness / 10)
}"
></div>
</div>
<span class="stat-value" :class="{ 'warning': happiness < 30 }">{{ happiness }}</span>
</div>
<div class="stat-row">
<div class="stat-icon pixel-food"></div>
<div class="pixel-bar">
<div
v-for="i in 10"
:key="'food-' + i"
class="pixel-block"
:class="{
'filled': i <= Math.floor(hunger / 10),
'color-yellow': i <= Math.floor(hunger / 10)
}"
></div>
</div>
<span class="stat-value" :class="{ 'warning': hunger < 30 }">{{ hunger }}</span>
</div>
<div class="stat-row">
<div class="stat-icon pixel-health"></div>
<div class="pixel-bar">
<div
v-for="i in 10"
:key="'health-' + i"
class="pixel-block"
:class="{
'filled': i <= Math.floor(health / 10),
'color-green': i <= Math.floor(health / 10)
}"
></div>
</div>
<span class="stat-value" :class="{ 'warning': health < 30 }">{{ health }}</span>
</div>
</div>
</template>
<script setup>
const props = defineProps({
hunger: {
type: Number,
default: 100
},
happiness: {
type: Number,
default: 100
},
health: {
type: Number,
default: 100
}
});
</script>
<style scoped>
.stats-bar {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 8px 3px 8px;
background: rgba(155, 188, 15, 0.08);
border-bottom: 2px solid rgba(0, 0, 0, 0.1);
}
.stat-row {
display: flex;
align-items: center;
gap: 5px;
}
.stat-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
position: relative;
}
/* Pixel Heart Icon */
.pixel-heart::before {
content: '';
position: absolute;
width: 2px;
height: 2px;
background: #ff4444;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-shadow:
/* Row 1 */
-4px -4px 0 #ff4444, -2px -4px 0 #ff4444,
2px -4px 0 #ff4444, 4px -4px 0 #ff4444,
/* Row 2 */
-6px -2px 0 #ff4444, -4px -2px 0 #ff4444, -2px -2px 0 #ff4444, 0px -2px 0 #ff4444,
2px -2px 0 #ff4444, 4px -2px 0 #ff4444, 6px -2px 0 #ff4444,
/* Row 3 */
-6px 0px 0 #ff4444, -4px 0px 0 #ff4444, -2px 0px 0 #ff4444, 0px 0px 0 #ff4444,
2px 0px 0 #ff4444, 4px 0px 0 #ff4444, 6px 0px 0 #ff4444,
/* Row 4 */
-4px 2px 0 #ff4444, -2px 2px 0 #ff4444, 0px 2px 0 #ff4444,
2px 2px 0 #ff4444, 4px 2px 0 #ff4444,
/* Row 5 */
-2px 4px 0 #ff4444, 0px 4px 0 #ff4444, 2px 4px 0 #ff4444,
/* Row 6 */
0px 6px 0 #ff4444;
}
/* Pixel Food/Meat Icon */
.pixel-food::before {
content: '';
position: absolute;
width: 2px;
height: 2px;
background: #cc6633;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-shadow:
/* Bone shape */
-4px -4px 0 #ffaa88, -2px -4px 0 #ffaa88,
-6px -2px 0 #ffaa88, -4px -2px 0 #ffaa88, -2px -2px 0 #cc6633, 0px -2px 0 #cc6633,
-4px 0px 0 #ffaa88, -2px 0px 0 #cc6633, 0px 0px 0 #cc6633,
-2px 2px 0 #cc6633, 0px 2px 0 #cc6633, 2px 2px 0 #cc6633, 4px 2px 0 #ffaa88, 6px 2px 0 #ffaa88,
-2px 4px 0 #ffaa88, 0px 4px 0 #ffaa88, 2px 4px 0 #cc6633, 4px 4px 0 #ffaa88;
}
/* Pixel Health/Cross Icon */
.pixel-health::before {
content: '';
position: absolute;
width: 2px;
height: 2px;
background: #44cc44;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-shadow:
/* Vertical bar */
0px -6px 0 #44cc44, 0px -4px 0 #44cc44,
/* Horizontal bar */
-6px -2px 0 #44cc44, -4px -2px 0 #44cc44, -2px -2px 0 #44cc44, 0px -2px 0 #44cc44,
2px -2px 0 #44cc44, 4px -2px 0 #44cc44, 6px -2px 0 #44cc44,
/* Vertical continue */
0px 0px 0 #44cc44, 0px 2px 0 #44cc44,
0px 4px 0 #44cc44, 0px 6px 0 #44cc44;
}
.pixel-bar {
display: flex;
flex: 1;
border: 2px solid #000;
background: #000;
border-radius: 4px;
overflow: hidden;
}
.pixel-block {
flex: 1;
height: 10px;
background: #ddd;
border-right: 2px solid #000;
transition: background-color 0.2s ease;
}
.pixel-block:last-child {
border-right: none;
}
.pixel-block.filled.color-red {
background: linear-gradient(to bottom,
#ff6666 0%,
#ff4444 40%,
#dd2222 100%
);
box-shadow:
0 0 4px rgba(255, 68, 68, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.5),
inset 0 -1px 0 rgba(0, 0, 0, 0.3);
}
.pixel-block.filled.color-yellow {
background: linear-gradient(to bottom,
#ffee66 0%,
#ffdd44 40%,
#ddaa00 100%
);
box-shadow:
0 0 4px rgba(255, 204, 0, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.5),
inset 0 -1px 0 rgba(0, 0, 0, 0.3);
}
.pixel-block.filled.color-green {
background: linear-gradient(to bottom,
#66ee66 0%,
#44cc44 40%,
#228822 100%
);
box-shadow:
0 0 4px rgba(68, 204, 68, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.5),
inset 0 -1px 0 rgba(0, 0, 0, 0.3);
}
.stat-value {
font-family: monospace;
font-size: 10px;
font-weight: bold;
color: #333;
width: 28px;
text-align: right;
}
.stat-value.warning {
color: #ff0000;
animation: pulse-warning 1s ease-in-out infinite;
}
@keyframes pulse-warning {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>

118
src/components/TopMenu.vue Normal file
View File

@ -0,0 +1,118 @@
<template>
<div class="top-menu">
<button class="icon-btn icon-stats" @click="$emit('stats')" title="Stats"></button>
<button class="icon-btn icon-feed" @click="$emit('feed')" :disabled="disabled" title="Feed"></button>
<button class="icon-btn icon-play" @click="$emit('play')" :disabled="disabled" title="Play"></button>
<button class="icon-btn icon-sleep" @click="$emit('sleep')" :disabled="disabled" title="Sleep"></button>
</div>
</template>
<script setup>
const props = defineProps({
disabled: {
type: Boolean,
default: false
}
});
defineEmits(['stats', 'feed', 'play', 'sleep']);
</script>
<style scoped>
.top-menu {
display: flex;
justify-content: space-around;
align-items: center;
padding: 6px 12px;
background: rgba(155, 188, 15, 0.05);
border-bottom: 2px solid rgba(0, 0, 0, 0.1);
}
.icon-btn {
width: 16px;
height: 16px;
border: none;
background: transparent;
cursor: pointer;
position: relative;
padding: 0;
}
.icon-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Stats Icon (Bar Chart) */
.icon-stats::before {
content: '';
position: absolute;
width: 2px;
height: 2px;
background: #333;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-shadow:
-6px 4px 0 #333, -6px 2px 0 #333, -6px 0px 0 #333,
-2px 4px 0 #333, -2px 2px 0 #333, -2px 0px 0 #333, -2px -2px 0 #333,
2px 4px 0 #333, 2px 2px 0 #333, 2px 0px 0 #333, 2px -2px 0 #333, 2px -4px 0 #333,
6px 4px 0 #333, 6px 2px 0 #333;
}
/* Feed Icon (Apple/Food) */
.icon-feed::before {
content: '';
position: absolute;
width: 2px;
height: 2px;
background: #ff4444;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-shadow:
-2px -6px 0 #228822,
-4px -2px 0 #ff4444, -2px -2px 0 #ff4444, 0px -2px 0 #ff4444, 2px -2px 0 #ff4444,
-4px 0px 0 #ff4444, -2px 0px 0 #ff4444, 0px 0px 0 #ff4444, 2px 0px 0 #ff4444, 4px 0px 0 #ff4444,
-4px 2px 0 #ff4444, -2px 2px 0 #ff4444, 0px 2px 0 #ff4444, 2px 2px 0 #ff4444, 4px 2px 0 #ff4444,
-2px 4px 0 #ff4444, 0px 4px 0 #ff4444, 2px 4px 0 #ff4444;
}
/* Play Icon (Ball/Game) */
.icon-play::before {
content: '';
position: absolute;
width: 2px;
height: 2px;
background: #4444ff;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-shadow:
-2px -4px 0 #4444ff, 0px -4px 0 #4444ff, 2px -4px 0 #4444ff,
-4px -2px 0 #4444ff, -2px -2px 0 #4444ff, 0px -2px 0 #4444ff, 2px -2px 0 #4444ff, 4px -2px 0 #4444ff,
-4px 0px 0 #4444ff, -2px 0px 0 #4444ff, 0px 0px 0 #4444ff, 2px 0px 0 #4444ff, 4px 0px 0 #4444ff,
-4px 2px 0 #4444ff, -2px 2px 0 #4444ff, 0px 2px 0 #4444ff, 2px 2px 0 #4444ff, 4px 2px 0 #4444ff,
-2px 4px 0 #4444ff, 0px 4px 0 #4444ff, 2px 4px 0 #4444ff;
}
/* Sleep Icon (Moon) */
.icon-sleep::before {
content: '';
position: absolute;
width: 2px;
height: 2px;
background: #ffcc00;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-shadow:
0px -6px 0 #ffcc00, 2px -6px 0 #ffcc00,
-2px -4px 0 #ffcc00, 0px -4px 0 #ffcc00, 2px -4px 0 #ffcc00, 4px -4px 0 #ffcc00,
-2px -2px 0 #ffcc00, 0px -2px 0 #ffcc00, 4px -2px 0 #ffcc00,
-2px 0px 0 #ffcc00, 0px 0px 0 #ffcc00, 4px 0px 0 #ffcc00,
-2px 2px 0 #ffcc00, 0px 2px 0 #ffcc00, 4px 2px 0 #ffcc00,
-2px 4px 0 #ffcc00, 0px 4px 0 #ffcc00, 2px 4px 0 #ffcc00, 4px 4px 0 #ffcc00,
0px 6px 0 #ffcc00, 2px 6px 0 #ffcc00;
}
</style>

View File

@ -0,0 +1,191 @@
import { ref, computed, onMounted, onUnmounted } from 'vue';
export function usePetSystem() {
// --- State ---
const stage = ref('egg'); // egg, baby, adult
const state = ref('idle'); // idle, sleep, eating, sick, dead, refuse
// --- Stats ---
const stats = ref({
hunger: 100, // 0-100 (0 = Starving)
happiness: 100, // 0-100 (0 = Depressed)
health: 100, // 0-100 (0 = Sick risk)
weight: 500, // grams
age: 0, // days
poopCount: 0 // Number of poops on screen
});
// --- Internal Timers ---
let gameLoopId = null;
const TICK_RATE = 3000; // 3 seconds per tick
const isCleaning = ref(false);
// --- Actions ---
function feed() {
if (state.value === 'sleep' || state.value === 'dead' || stage.value === 'egg' || isCleaning.value) return false;
if (state.value === 'sick' || stats.value.hunger >= 90) {
// Refuse food if sick or full
triggerState('refuse', 2000);
return false;
}
// Eat
triggerState('eating', 3000); // Animation duration
stats.value.hunger = Math.min(100, stats.value.hunger + 20);
stats.value.weight += 50;
// Chance to poop after eating
if (Math.random() < 0.3) {
setTimeout(() => {
if (stats.value.poopCount < 4) {
stats.value.poopCount++;
}
}, 4000);
}
return true;
}
function play() {
if (state.value !== 'idle' || stage.value === 'egg' || isCleaning.value) return false;
stats.value.happiness = Math.min(100, stats.value.happiness + 15);
stats.value.weight -= 10; // Exercise burns calories
stats.value.hunger = Math.max(0, stats.value.hunger - 5);
return true;
}
function clean() {
if (stats.value.poopCount > 0 && !isCleaning.value) {
isCleaning.value = true;
// Delay removal for animation
setTimeout(() => {
stats.value.poopCount = 0;
stats.value.happiness += 10;
isCleaning.value = false;
}, 2000); // 2 seconds flush animation
return true;
}
return false;
}
function sleep() {
if (isCleaning.value) return;
if (state.value === 'idle') {
state.value = 'sleep';
} else if (state.value === 'sleep') {
state.value = 'idle'; // Wake up
}
}
// --- Game Loop ---
function tick() {
if (state.value === 'dead' || stage.value === 'egg') return;
// Decrease stats naturally
if (state.value !== 'sleep') {
stats.value.hunger = Math.max(0, stats.value.hunger - 2);
stats.value.happiness = Math.max(0, stats.value.happiness - 1);
} else {
// Slower decay when sleeping
stats.value.hunger = Math.max(0, stats.value.hunger - 0.5);
}
// Random poop generation (5% chance per tick)
if (state.value !== 'sleep' && Math.random() < 0.05 && stats.value.poopCount < 4 && !isCleaning.value) {
stats.value.poopCount++;
}
// Health Logic (Poop hurts health)
if (stats.value.poopCount > 0) {
stats.value.health = Math.max(0, stats.value.health - (2 * stats.value.poopCount));
}
if (stats.value.hunger === 0) {
stats.value.health = Math.max(0, stats.value.health - 5);
}
// Sickness Check
if (stats.value.health < 30 && state.value !== 'sick') {
if (Math.random() < 0.3) {
state.value = 'sick';
}
}
// Death Check
if (stats.value.health === 0) {
state.value = 'dead';
}
// Evolution / Growth (Simple Age increment)
// In a real game, 1 day might be 24h, here maybe every 100 ticks?
// For now, let's just say age increases slowly.
}
// --- Helpers ---
function triggerState(tempState, duration) {
const previousState = state.value;
state.value = tempState;
setTimeout(() => {
if (state.value === tempState) { // Only revert if state hasn't changed again
state.value = previousState === 'sleep' ? 'idle' : 'idle';
}
}, duration);
}
function hatchEgg() {
if (stage.value === 'egg') {
stage.value = 'baby'; // or 'adult' for now since we only have that sprite
// Let's map 'baby' to our 'adult' sprite for now, or just use 'adult'
stage.value = 'adult';
state.value = 'idle';
stats.value.hunger = 50;
stats.value.happiness = 50;
stats.value.health = 100;
stats.value.poopCount = 0;
isCleaning.value = false;
}
}
function reset() {
stage.value = 'egg';
state.value = 'idle';
isCleaning.value = false;
stats.value = {
hunger: 100,
happiness: 100,
health: 100,
weight: 500,
age: 0,
poopCount: 0
};
}
// --- Lifecycle ---
onMounted(() => {
gameLoopId = setInterval(tick, TICK_RATE);
});
onUnmounted(() => {
if (gameLoopId) clearInterval(gameLoopId);
});
return {
stage,
state,
stats,
isCleaning,
feed,
play,
clean,
sleep,
hatchEgg,
reset
};
}

View File

@ -203,6 +203,28 @@ export const SPRITE_PRESETS = {
blushPixels: [
[3, 7], [10, 7]
],
eyePixels: [
[3, 6], [4, 6], // Left eye
[8, 6], [9, 6] // Right eye
],
spriteEyesClosed: [
'0000000000000000',
'0011000000110000',
'0124444111442100',
'0123222323221000',
'0122322223221000',
'0122522222522100',
'0122222222222100', // row 6 - Eyes closed (all '2' = closed eyes)
'0112223322221100',
'0122220222221000',
'0011222222110000',
'0001222222121000',
'0001222222121000',
'0001100110110000',
'0000000000000000',
'0000000000000000',
'0000000000000000',
],
iconBackLeft: { x: 2, y: 2 },
iconBackRight: { x: 13, y: 2 },
@ -231,5 +253,4 @@ export const SPRITE_PRESETS = {
'3': '#ffb74d', // Orange tiger stripes
}
}
};