diff --git a/src/App.vue b/src/App.vue index f9dae89..6bbbc8e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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; +} diff --git a/src/components/PetGame.vue b/src/components/PetGame.vue index 1359201..f34a435 100644 --- a/src/components/PetGame.vue +++ b/src/components/PetGame.vue @@ -1,77 +1,146 @@ @@ -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({ diff --git a/src/components/TopMenu.vue b/src/components/TopMenu.vue new file mode 100644 index 0000000..b63479d --- /dev/null +++ b/src/components/TopMenu.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/src/composables/usePetSystem.js b/src/composables/usePetSystem.js new file mode 100644 index 0000000..e574d8d --- /dev/null +++ b/src/composables/usePetSystem.js @@ -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 + }; +} diff --git a/src/data/petPresets.js b/src/data/petPresets.js index 72def21..f44cba4 100644 --- a/src/data/petPresets.js +++ b/src/data/petPresets.js @@ -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 } } - };