Compare commits

...

1 Commits

Author SHA1 Message Date
王性驊 bae581b778 feat:merge health 2025-11-21 16:26:40 +08:00
11 changed files with 1147 additions and 473 deletions

View File

@ -8,7 +8,16 @@ import { usePetSystem } from './composables/usePetSystem';
const currentScreen = ref('game'); const currentScreen = ref('game');
const petGameRef = ref(null); const petGameRef = ref(null);
const showStats = ref(false); // Stats visibility const statsMode = ref('none'); // 'none', 'bars', 'details'
// Menu State
const isMenuOpen = ref(false);
const selectedMenuIndex = ref(0);
// Combined menu items: Top (0-3) + Bottom (4-7)
const MENU_ITEMS = [
'stats', 'feed', 'play', 'sleep', // Top Menu
'clean', 'medicine', 'training', 'info' // Action Menu
];
// Initialize Pet System // Initialize Pet System
const { const {
@ -24,8 +33,48 @@ const {
reset reset
} = usePetSystem(); } = usePetSystem();
// Handle Physical Buttons
function handleButton(btnId) {
console.log('Button pressed:', btnId);
// 0. Check if PetGame wants to handle the input (e.g. Prayer Menu, Minigames)
if (petGameRef.value && petGameRef.value.handleInput) {
if (petGameRef.value.handleInput(btnId)) {
return; // Input handled by game component
}
}
// 1. Idle State (Menu Closed)
if (!isMenuOpen.value) {
if (btnId === 1) {
// Button 1: Open Menu
isMenuOpen.value = true;
selectedMenuIndex.value = 0; // Default to first item
}
// Buttons 2 & 3 do nothing in idle state (or could trigger other things)
return;
}
// 2. Menu Active State
if (isMenuOpen.value) {
if (btnId === 2) {
// Button 2: Left (Previous)
selectedMenuIndex.value = (selectedMenuIndex.value - 1 + MENU_ITEMS.length) % MENU_ITEMS.length;
} else if (btnId === 3) {
// Button 3: Right (Next)
selectedMenuIndex.value = (selectedMenuIndex.value + 1) % MENU_ITEMS.length;
} else if (btnId === 1) {
// Button 1: Confirm
const action = MENU_ITEMS[selectedMenuIndex.value];
handleAction(action);
isMenuOpen.value = false; // Close menu after selection
}
}
}
// Handle Action Menu Events // Handle Action Menu Events
function handleAction(action) { function handleAction(action) {
console.log('Action triggered:', action);
switch(action) { switch(action) {
case 'feed': case 'feed':
const feedResult = feed(); const feedResult = feed();
@ -51,8 +100,25 @@ function handleAction(action) {
} }
break; break;
case 'stats': case 'stats':
// Toggle stats display // Toggle stats mode: none -> bars -> details -> none
showStats.value = !showStats.value; if (statsMode.value === 'none') statsMode.value = 'bars';
else if (statsMode.value === 'bars') statsMode.value = 'details';
else statsMode.value = 'none';
break;
case 'info':
// Show info (same as stats details for now, or separate)
if (statsMode.value !== 'details') statsMode.value = 'details';
else statsMode.value = 'none';
break;
case 'training':
// Show Prayer Menu (handled in PetGame via prop or event?)
// We need to pass this down or handle it here.
// Currently PetGame handles 'training' event to show menu.
// We can just emit the action to PetGame if we move logic there,
// or better, expose a method on PetGame.
if (petGameRef.value) {
petGameRef.value.openPrayerMenu();
}
break; break;
case 'settings': case 'settings':
// Show reset options // Show reset options
@ -62,16 +128,6 @@ function handleAction(action) {
reset(); reset();
} }
break; break;
case 'jiaobei':
// -
console.log('擲筊功能');
// TODO:
break;
case 'fortune':
// -
console.log('求籤功能');
// TODO:
break;
default: default:
console.log('Action not implemented:', action); console.log('Action not implemented:', action);
} }
@ -84,7 +140,7 @@ function setPetState(newState) {
</script> </script>
<template> <template>
<DeviceShell> <DeviceShell @btn1="handleButton(1)" @btn2="handleButton(2)" @btn3="handleButton(3)">
<DeviceScreen> <DeviceScreen>
<!-- Dynamic Component Switching --> <!-- Dynamic Component Switching -->
<PetGame <PetGame
@ -94,7 +150,9 @@ function setPetState(newState) {
:stage="stage" :stage="stage"
:stats="stats" :stats="stats"
:isCleaning="isCleaning" :isCleaning="isCleaning"
:showStats="showStats" :statsMode="statsMode"
:isMenuOpen="isMenuOpen"
:selectedMenuIndex="selectedMenuIndex"
@update:state="state = $event" @update:state="state = $event"
@action="handleAction" @action="handleAction"
/> />

View File

@ -1,9 +1,33 @@
<template> <template>
<div class="action-menu"> <div class="action-menu">
<button class="icon-btn icon-clean" @click="$emit('clean')" :disabled="disabled || poopCount === 0" title="清理"></button> <button
<button class="icon-btn icon-medicine" @click="$emit('medicine')" :disabled="disabled || !isSick" title="治療"></button> class="icon-btn icon-clean"
<button class="icon-btn icon-training" @click="$emit('training')" :disabled="disabled" title="祈禱"></button> :class="{ active: isMenuOpen && selectedIndex === 4 }"
<button class="icon-btn icon-info" @click="$emit('info')" :disabled="disabled" title="資訊"></button> @click="$emit('clean')"
:disabled="disabled || poopCount === 0"
title="清理"
></button>
<button
class="icon-btn icon-medicine"
:class="{ active: isMenuOpen && selectedIndex === 5 }"
@click="$emit('medicine')"
:disabled="disabled || !isSick"
title="治療"
></button>
<button
class="icon-btn icon-training"
:class="{ active: isMenuOpen && selectedIndex === 6 }"
@click="$emit('training')"
:disabled="disabled"
title="祈禱"
></button>
<button
class="icon-btn icon-info"
:class="{ active: isMenuOpen && selectedIndex === 7 }"
@click="$emit('info')"
:disabled="disabled"
title="資訊"
></button>
</div> </div>
</template> </template>
@ -24,6 +48,14 @@ const props = defineProps({
isSick: { isSick: {
type: Boolean, type: Boolean,
default: false default: false
},
isMenuOpen: {
type: Boolean,
default: false
},
selectedIndex: {
type: Number,
default: 0
} }
}); });
@ -48,10 +80,20 @@ defineEmits(['clean', 'medicine', 'training', 'info']);
cursor: pointer; cursor: pointer;
position: relative; position: relative;
padding: 0; padding: 0;
opacity: 0.5; /* Default dim */
transition: opacity 0.2s, transform 0.2s;
}
.icon-btn.active {
opacity: 1;
transform: scale(1.1);
filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));
background: rgba(255, 255, 255, 0.5);
border-radius: 4px;
} }
.icon-btn:disabled { .icon-btn:disabled {
opacity: 0.3; opacity: 0.2;
cursor: not-allowed; cursor: not-allowed;
} }

View File

@ -7,9 +7,9 @@
</div> </div>
<div class="buttons"> <div class="buttons">
<div class="btn"></div> <div class="btn" @click="$emit('btn1')"></div>
<div class="btn"></div> <div class="btn" @click="$emit('btn2')"></div>
<div class="btn"></div> <div class="btn" @click="$emit('btn3')"></div>
</div> </div>
</div> </div>
</template> </template>

View File

@ -22,13 +22,13 @@
</div> </div>
<div v-if="!isShaking && selectedStick" class="action-buttons"> <div v-if="!isShaking && selectedStick" class="action-buttons">
<button class="pixel-btn confirm-btn" @click="handleConfirm">擲筊確認</button> <button class="pixel-btn confirm-btn" :class="{ active: selectedIndex === 0 }" @click="handleConfirm">擲筊確認</button>
<button class="pixel-btn close-btn" @click="$emit('close')">返回</button> <button class="pixel-btn close-btn" :class="{ active: selectedIndex === 1 }" @click="$emit('close')">返回</button>
</div> </div>
<!-- 在搖動時也顯示返回按鈕但位置可能需要調整 --> <!-- 在搖動時也顯示返回按鈕但位置可能需要調整 -->
<div v-if="isShaking" class="action-buttons"> <div v-if="isShaking" class="action-buttons">
<button class="pixel-btn close-btn" @click="$emit('close')">返回</button> <button class="pixel-btn close-btn" :class="{ active: selectedIndex === 0 }" @click="$emit('close')">返回</button>
</div> </div>
</div> </div>
</div> </div>
@ -37,6 +37,13 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
const props = defineProps({
selectedIndex: {
type: Number,
default: 0
}
});
const emit = defineEmits(['complete', 'close']); const emit = defineEmits(['complete', 'close']);
const isShaking = ref(true); const isShaking = ref(true);
@ -68,6 +75,11 @@ function handleConfirm() {
onMounted(() => { onMounted(() => {
startShake(); startShake();
}); });
defineExpose({
hasFallen,
handleConfirm
});
</script> </script>
<style scoped> <style scoped>
@ -220,7 +232,7 @@ onMounted(() => {
.status-text { .status-text {
font-size: 12px; font-size: 12px;
color: #fff; color: #fff;
text-shadow: 1px 1px 0 #000; /* text-shadow: 1px 1px 0 #000; Removed to fix ghosting */
min-height: 18px; min-height: 18px;
} }
@ -240,7 +252,7 @@ onMounted(() => {
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
transition: all 0.1s; transition: all 0.1s;
text-shadow: 1px 1px 0 #000; /* text-shadow: 1px 1px 0 #000; Removed to fix ghosting */
box-shadow: 2px 2px 0 rgba(0,0,0,0.5); box-shadow: 2px 2px 0 rgba(0,0,0,0.5);
} }
@ -254,6 +266,14 @@ onMounted(() => {
box-shadow: 1px 1px 0 rgba(0,0,0,0.5); box-shadow: 1px 1px 0 rgba(0,0,0,0.5);
} }
.pixel-btn.active {
transform: scale(1.1);
background: #fff;
color: #000;
border-color: #000;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
}
.confirm-btn { .confirm-btn {
border-color: #ffcc00; border-color: #ffcc00;
color: #ffcc00; color: #ffcc00;

View File

@ -30,19 +30,19 @@
<div v-if="!isTossing" class="action-buttons"> <div v-if="!isTossing" class="action-buttons">
<!-- 一般模式 --> <!-- 一般模式 -->
<template v-if="mode === 'normal'"> <template v-if="mode === 'normal'">
<button class="pixel-btn retry-btn" @click="startToss">再一次</button> <button class="pixel-btn retry-btn" :class="{ active: selectedIndex === 0 }" @click="startToss">再一次</button>
<button class="pixel-btn close-btn" @click="handleClose">關閉</button> <button class="pixel-btn close-btn" :class="{ active: selectedIndex === 1 }" @click="handleClose">關閉</button>
</template> </template>
<!-- 求籤模式 --> <!-- 求籤模式 -->
<template v-else> <template v-else>
<!-- 失敗 (非聖筊) --> <!-- 失敗 (非聖筊) -->
<button v-if="resultType !== 'saint'" class="pixel-btn close-btn" @click="handleRetryFortune">重新求籤</button> <button v-if="resultType !== 'saint'" class="pixel-btn close-btn" :class="{ active: selectedIndex === 0 }" @click="handleRetryFortune">重新求籤</button>
<!-- 成功 (聖筊) --> <!-- 成功 (聖筊) -->
<template v-else> <template v-else>
<button v-if="consecutiveCount < 2" class="pixel-btn retry-btn" @click="startToss">繼續擲筊</button> <button v-if="consecutiveCount < 2" class="pixel-btn retry-btn" :class="{ active: selectedIndex === 0 }" @click="startToss">繼續擲筊</button>
<button v-else class="pixel-btn retry-btn" @click="$emit('finish-fortune')">查看籤詩</button> <button v-else class="pixel-btn retry-btn" :class="{ active: selectedIndex === 0 }" @click="$emit('finish-fortune')">查看籤詩</button>
</template> </template>
</template> </template>
</div> </div>
@ -61,6 +61,10 @@ const props = defineProps({
consecutiveCount: { consecutiveCount: {
type: Number, type: Number,
default: 0 default: 0
},
selectedIndex: {
type: Number,
default: 0
} }
}); });
@ -125,6 +129,10 @@ function handleRetryFortune() {
onMounted(() => { onMounted(() => {
startToss(); startToss();
}); });
defineExpose({
startToss
});
</script> </script>
<style scoped> <style scoped>
@ -264,7 +272,7 @@ onMounted(() => {
color: #fff; color: #fff;
text-align: center; text-align: center;
min-height: 20px; min-height: 20px;
text-shadow: 1px 1px 0 #000; /* text-shadow: 1px 1px 0 #000; Removed to fix ghosting */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -302,7 +310,7 @@ onMounted(() => {
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
transition: all 0.1s; transition: all 0.1s;
text-shadow: 1px 1px 0 #000; /* text-shadow: 1px 1px 0 #000; Removed to fix ghosting */
box-shadow: 2px 2px 0 rgba(0,0,0,0.5); box-shadow: 2px 2px 0 rgba(0,0,0,0.5);
} }
@ -316,6 +324,14 @@ onMounted(() => {
box-shadow: 1px 1px 0 rgba(0,0,0,0.5); box-shadow: 1px 1px 0 rgba(0,0,0,0.5);
} }
.pixel-btn.active {
transform: scale(1.1);
background: #fff;
color: #000;
border-color: #000;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
}
.retry-btn { .retry-btn {
border-color: #ffcc00; border-color: #ffcc00;
color: #ffcc00; color: #ffcc00;

View File

@ -3,6 +3,8 @@
<!-- Top Menu --> <!-- Top Menu -->
<TopMenu <TopMenu
:disabled="stage === 'egg'" :disabled="stage === 'egg'"
:isMenuOpen="isMenuOpen"
:selectedIndex="selectedMenuIndex"
@stats="$emit('action', 'stats')" @stats="$emit('action', 'stats')"
@feed="$emit('action', 'feed')" @feed="$emit('action', 'feed')"
@play="$emit('action', 'play')" @play="$emit('action', 'play')"
@ -11,12 +13,18 @@
<!-- Stats Dashboard (Toggelable) --> <!-- Stats Dashboard (Toggelable) -->
<StatsBar <StatsBar
v-if="showStats" v-if="statsMode === 'bars'"
:hunger="stats?.hunger || 100" :hunger="stats?.hunger || 100"
:happiness="stats?.happiness || 100" :happiness="stats?.happiness || 100"
:health="stats?.health || 100" :health="stats?.health || 100"
/> />
<!-- Detailed Info Overlay -->
<PetInfo
v-if="statsMode === 'details'"
:stats="stats"
/>
<!-- Game Area (Center) --> <!-- Game Area (Center) -->
<div class="pet-game-container" ref="containerRef"> <div class="pet-game-container" ref="containerRef">
<!-- 關燈黑色遮罩 --> <!-- 關燈黑色遮罩 -->
@ -133,6 +141,7 @@
<!-- Prayer Menu (覆蓋整個遊戲區域) --> <!-- Prayer Menu (覆蓋整個遊戲區域) -->
<PrayerMenu <PrayerMenu
v-if="showPrayerMenu" v-if="showPrayerMenu"
:selectedIndex="prayerMenuIndex"
@select="handlePrayerSelect" @select="handlePrayerSelect"
@close="showPrayerMenu = false" @close="showPrayerMenu = false"
/> />
@ -140,8 +149,10 @@
<!-- Jiaobei Animation (覆蓋遊戲區域) --> <!-- Jiaobei Animation (覆蓋遊戲區域) -->
<JiaobeiAnimation <JiaobeiAnimation
v-if="showJiaobeiAnimation" v-if="showJiaobeiAnimation"
ref="jiaobeiRef"
:mode="fortuneMode" :mode="fortuneMode"
:consecutiveCount="consecutiveSaintCount" :consecutiveCount="consecutiveSaintCount"
:selectedIndex="jiaobeiIndex"
@close="handleJiaobeiClose" @close="handleJiaobeiClose"
@result="handleJiaobeiResult" @result="handleJiaobeiResult"
@retry-fortune="handleRetryFortune" @retry-fortune="handleRetryFortune"
@ -151,6 +162,8 @@
<!-- Fortune Stick Animation --> <!-- Fortune Stick Animation -->
<FortuneStickAnimation <FortuneStickAnimation
v-if="showFortuneStick" v-if="showFortuneStick"
ref="fortuneStickRef"
:selectedIndex="fortuneStickIndex"
@complete="handleStickComplete" @complete="handleStickComplete"
@close="handleFortuneStickClose" @close="handleFortuneStickClose"
/> />
@ -168,6 +181,8 @@
:poopCount="stats?.poopCount || 0" :poopCount="stats?.poopCount || 0"
:health="stats?.health || 100" :health="stats?.health || 100"
:isSick="state === 'sick'" :isSick="state === 'sick'"
:isMenuOpen="isMenuOpen"
:selectedIndex="selectedMenuIndex"
@clean="$emit('action', 'clean')" @clean="$emit('action', 'clean')"
@medicine="$emit('action', 'medicine')" @medicine="$emit('action', 'medicine')"
@training="showPrayerMenu = true" @training="showPrayerMenu = true"
@ -182,6 +197,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { SPRITE_PRESETS } from '../data/petPresets.js'; import { SPRITE_PRESETS } from '../data/petPresets.js';
import { FOOD_OPTIONS } from '../data/foodOptions.js'; import { FOOD_OPTIONS } from '../data/foodOptions.js';
import StatsBar from './StatsBar.vue'; import StatsBar from './StatsBar.vue';
import PetInfo from './PetInfo.vue';
import ActionMenu from './ActionMenu.vue'; import ActionMenu from './ActionMenu.vue';
import TopMenu from './TopMenu.vue'; import TopMenu from './TopMenu.vue';
import PrayerMenu from './PrayerMenu.vue'; import PrayerMenu from './PrayerMenu.vue';
@ -203,13 +219,21 @@ const props = defineProps({
type: Object, type: Object,
default: () => ({ hunger: 100, happiness: 100, health: 100, poopCount: 0 }) default: () => ({ hunger: 100, happiness: 100, health: 100, poopCount: 0 })
}, },
showStats: { statsMode: {
type: Boolean, type: String,
default: false default: 'none' // 'none', 'bars', 'details'
}, },
isCleaning: { isCleaning: {
type: Boolean, type: Boolean,
default: false default: false
},
isMenuOpen: {
type: Boolean,
default: false
},
selectedMenuIndex: {
type: Number,
default: 0
} }
}); });
@ -264,16 +288,7 @@ function handleFortuneStickClose() {
consecutiveSaintCount.value = 0; consecutiveSaintCount.value = 0;
} }
function handleJiaobeiResult(type) {
if (fortuneMode.value === 'fortune') {
if (type === 'saint') {
consecutiveSaintCount.value++;
} else {
//
consecutiveSaintCount.value = 0;
}
}
}
function handleRetryFortune() { function handleRetryFortune() {
// //
@ -379,11 +394,38 @@ const FOOD_PALETTE = FOOD_OPTIONS[currentFood].palette;
const CURRENT_PRESET = SPRITE_PRESETS.tinyTigerCatB; const CURRENT_PRESET = SPRITE_PRESETS.tinyTigerCatB;
const pixelSize = CURRENT_PRESET.pixelSize; // Use meta.pixelSize if available, fallback to root pixelSize (for backward compatibility)
const pixelSize = CURRENT_PRESET.appearance?.pixelSize || CURRENT_PRESET.pixelSize || 3;
// Define dimensions // Helper to get current stage config
const rows = CURRENT_PRESET.sprite.length; const currentStageConfig = computed(() => {
const cols = CURRENT_PRESET.sprite[0].length; if (!CURRENT_PRESET.lifecycle) return null;
return CURRENT_PRESET.lifecycle.stages.find(s => s.id === props.stage);
});
// Helper to get sprite key for current stage
const currentSpriteKey = computed(() => {
if (currentStageConfig.value) {
return currentStageConfig.value.spriteKey;
}
return props.stage === 'egg' ? 'egg' : 'child'; // Fallback
});
// Define dimensions based on the first available sprite
// We assume all sprites have the same dimensions for now
const getBaseSprite = () => {
if (CURRENT_PRESET.appearance?.sprites) {
// Try to find a valid sprite to measure
const sprites = CURRENT_PRESET.appearance.sprites;
const firstKey = Object.keys(sprites)[0];
if (firstKey && sprites[firstKey].idle) return sprites[firstKey].idle;
}
return CURRENT_PRESET.sprite; // Fallback to old structure
};
const baseSprite = getBaseSprite();
const rows = baseSprite.length;
const cols = baseSprite[0].length;
const width = cols * pixelSize; const width = cols * pixelSize;
const height = rows * pixelSize; const height = rows * pixelSize;
@ -391,7 +433,9 @@ const height = rows * pixelSize;
// 2. Generate Pixels Helper // 2. Generate Pixels Helper
function generatePixels(spriteMap, paletteOverride = null) { function generatePixels(spriteMap, paletteOverride = null) {
const pxs = []; const pxs = [];
const palette = paletteOverride || CURRENT_PRESET.palette; // Handle new palette structure (appearance.palettes.default) vs old (palette)
const defaultPalette = CURRENT_PRESET.appearance?.palettes?.default || CURRENT_PRESET.palette;
const palette = paletteOverride || defaultPalette;
spriteMap.forEach((rowStr, y) => { spriteMap.forEach((rowStr, y) => {
[...rowStr].forEach((ch, x) => { [...rowStr].forEach((ch, x) => {
@ -400,11 +444,14 @@ function generatePixels(spriteMap, paletteOverride = null) {
// Only apply body part classes if NOT an egg // Only apply body part classes if NOT an egg
let className = ''; let className = '';
if (props.stage !== 'egg') { if (props.stage !== 'egg') {
const isTail = CURRENT_PRESET.tailPixels?.some(([tx, ty]) => tx === x && ty === y); // Handle new bodyParts structure vs old root properties
const isLegFront = CURRENT_PRESET.legFrontPixels?.some(([lx, ly]) => lx === x && ly === y); const bodyParts = CURRENT_PRESET.appearance?.bodyParts || CURRENT_PRESET;
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 isTail = bodyParts.tailPixels?.some(([tx, ty]) => tx === x && ty === y);
const isBlush = CURRENT_PRESET.blushPixels?.some(([bx, by]) => bx === x && by === y); const isLegFront = bodyParts.legFrontPixels?.some(([lx, ly]) => lx === x && ly === y);
const isLegBack = bodyParts.legBackPixels?.some(([lx, ly]) => lx === x && ly === y);
const isEar = bodyParts.earPixels?.some(([ex, ey]) => ex === x && ey === y);
const isBlush = bodyParts.blushPixels?.some(([bx, by]) => bx === x && by === y);
if (isTail) className += ' tail-pixel'; if (isTail) className += ' tail-pixel';
if (isLegFront) className += ' leg-front'; if (isLegFront) className += ' leg-front';
@ -447,12 +494,33 @@ const foodVisible = ref(false);
const isBlinking = ref(false); const isBlinking = ref(false);
const currentPixels = computed(() => { const currentPixels = computed(() => {
// Priority: Egg > Blink > Mouth Open > Normal // New Structure Logic
if (CURRENT_PRESET.appearance?.sprites) {
const sprites = CURRENT_PRESET.appearance.sprites;
const key = currentSpriteKey.value;
const stageSprites = sprites[key];
if (!stageSprites) return []; // Should not happen if config is correct
// Priority: Blink > Mouth Open > Normal
// Note: Egg stage is handled by currentSpriteKey returning 'egg' which maps to egg sprites
if (isBlinking.value && stageSprites.eyesClosed) {
return generatePixels(stageSprites.eyesClosed);
}
if (isMouthOpen.value && stageSprites.mouthOpen) {
return generatePixels(stageSprites.mouthOpen);
}
return generatePixels(stageSprites.idle);
}
// Fallback to Old Structure Logic
if (props.stage === 'egg') { if (props.stage === 'egg') {
return generatePixels(CURRENT_PRESET.eggSprite, CURRENT_PRESET.eggPalette); return generatePixels(CURRENT_PRESET.eggSprite, CURRENT_PRESET.eggPalette);
} }
// Blink overrides mouth state (when blinking, always show closed eyes)
if (isBlinking.value && CURRENT_PRESET.spriteEyesClosed) { if (isBlinking.value && CURRENT_PRESET.spriteEyesClosed) {
return generatePixels(CURRENT_PRESET.spriteEyesClosed); return generatePixels(CURRENT_PRESET.spriteEyesClosed);
} }
@ -484,7 +552,11 @@ const iconStyle = computed(() => ({
})); }));
function updateHeadIconsPosition() { function updateHeadIconsPosition() {
const { iconBackLeft, iconBackRight } = CURRENT_PRESET; const bodyParts = CURRENT_PRESET.appearance?.bodyParts || CURRENT_PRESET;
const { iconBackLeft, iconBackRight } = bodyParts;
if (!iconBackLeft || !iconBackRight) return;
const marker = isFacingRight.value ? iconBackLeft : iconBackRight; const marker = isFacingRight.value ? iconBackLeft : iconBackRight;
const baseX = petX.value + marker.x * pixelSize; const baseX = petX.value + marker.x * pixelSize;
const baseY = petY.value + marker.y * pixelSize; const baseY = petY.value + marker.y * pixelSize;
@ -786,9 +858,129 @@ async function shakeHead() {
isShakingHead.value = false; isShakingHead.value = false;
} }
// Expose shakeHead function to parent component // Menu Indices
const prayerMenuIndex = ref(0);
const jiaobeiIndex = ref(0);
const fortuneStickIndex = ref(0);
// Refs for child components
const jiaobeiRef = ref(null);
const fortuneStickRef = ref(null);
const lastJiaobeiResult = ref(''); // Track result for button logic
// Handle Physical Button Input
function handleInput(btnId) {
// 1. Fortune Result (Highest Priority)
if (showFortuneResult.value && currentLotData.value) {
if (btnId === 1) {
handleCloseResult();
}
return true;
}
// 2. Jiaobei Animation
if (showJiaobeiAnimation.value) {
let numOptions = 1;
if (fortuneMode.value === 'normal') {
numOptions = 2; // Retry, Close
} else {
numOptions = 1;
}
if (btnId === 2) {
jiaobeiIndex.value = (jiaobeiIndex.value - 1 + numOptions) % numOptions;
} else if (btnId === 3) {
jiaobeiIndex.value = (jiaobeiIndex.value + 1) % numOptions;
} else if (btnId === 1) {
if (fortuneMode.value === 'normal') {
if (jiaobeiIndex.value === 0) {
jiaobeiRef.value?.startToss();
} else {
handleJiaobeiClose();
}
} else {
if (lastJiaobeiResult.value !== 'saint') {
handleRetryFortune();
} else {
if (consecutiveSaintCount.value < 3) {
jiaobeiRef.value?.startToss();
} else {
handleFinishFortune();
}
}
}
}
return true;
}
// 3. Fortune Stick Animation
if (showFortuneStick.value) {
const isResult = fortuneStickRef.value?.hasFallen;
const numOptions = isResult ? 2 : 1;
if (btnId === 2) {
fortuneStickIndex.value = (fortuneStickIndex.value - 1 + numOptions) % numOptions;
} else if (btnId === 3) {
fortuneStickIndex.value = (fortuneStickIndex.value + 1) % numOptions;
} else if (btnId === 1) {
if (isResult) {
if (fortuneStickIndex.value === 0) {
fortuneStickRef.value?.handleConfirm();
} else {
handleFortuneStickClose();
}
} else {
handleFortuneStickClose();
}
}
return true;
}
// 4. Prayer Menu
if (showPrayerMenu.value) {
if (btnId === 2) {
prayerMenuIndex.value = (prayerMenuIndex.value - 1 + 3) % 3;
} else if (btnId === 3) {
prayerMenuIndex.value = (prayerMenuIndex.value + 1) % 3;
} else if (btnId === 1) {
if (prayerMenuIndex.value === 0) handlePrayerSelect('jiaobei');
else if (prayerMenuIndex.value === 1) handlePrayerSelect('fortune');
else showPrayerMenu.value = false;
}
return true;
}
// 5. Pet Info Overlay
if (props.statsMode === 'details') {
if (btnId === 1) {
emit('action', 'stats'); // Toggle off
}
return true;
}
return false;
}
function handleJiaobeiResult(result) {
lastJiaobeiResult.value = result;
if (result === 'saint' && fortuneMode.value === 'fortune') {
consecutiveSaintCount.value++;
} else if (fortuneMode.value === 'fortune') {
consecutiveSaintCount.value = 0;
}
}
// Open Prayer Menu
function openPrayerMenu() {
showPrayerMenu.value = true;
prayerMenuIndex.value = 0; // Reset index
}
// Expose functions to parent component
defineExpose({ defineExpose({
shakeHead shakeHead,
openPrayerMenu,
handleInput
}); });
</script> </script>
@ -798,6 +990,7 @@ defineExpose({
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative;
} }
.pet-game-container { .pet-game-container {

149
src/components/PetInfo.vue Normal file
View File

@ -0,0 +1,149 @@
<template>
<div class="pet-info-overlay">
<div class="info-card">
<div class="header">
<span class="name">{{ name }}</span>
<span class="species">{{ species }}</span>
</div>
<div class="description">
{{ description }}
</div>
<div class="stats-grid">
<div class="stat-item">
<span class="label">HP</span>
<span class="value">{{ Math.round(stats.health) }}/100</span>
</div>
<div class="stat-item">
<span class="label">ATK</span>
<span class="value">{{ baseStats.attack }}</span>
</div>
<div class="stat-item">
<span class="label">DEF</span>
<span class="value">{{ baseStats.defense }}</span>
</div>
<div class="stat-item">
<span class="label">SPD</span>
<span class="value">{{ baseStats.speed }}</span>
</div>
</div>
<div class="age-info">
Age: {{ Math.floor(stats.ageMinutes / 60) }}h {{ Math.floor(stats.ageMinutes % 60) }}m
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { SPRITE_PRESETS } from '../data/petPresets.js';
const props = defineProps({
stats: {
type: Object,
required: true
}
});
// For now, we assume tinyTigerCatB. In a real app, this might be a prop.
const CONFIG = SPRITE_PRESETS.tinyTigerCatB;
const name = computed(() => CONFIG.meta.name);
const species = computed(() => CONFIG.meta.displayNameEn);
const description = computed(() => CONFIG.meta.description);
const baseStats = computed(() => {
// Calculate current stats based on stage modifiers if needed
// For now just showing base stats from config
return CONFIG.stats.base;
});
</script>
<style scoped>
.pet-info-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
pointer-events: none; /* Let clicks pass through if needed, but usually overlay blocks */
}
.info-card {
background: #fff;
border: 2px solid #333;
border-radius: 8px;
padding: 12px;
width: 80%;
max-width: 280px;
font-family: monospace;
pointer-events: auto;
box-shadow: 4px 4px 0 rgba(0,0,0,0.2);
}
.header {
display: flex;
justify-content: space-between;
align-items: baseline;
border-bottom: 2px solid #eee;
padding-bottom: 4px;
margin-bottom: 8px;
}
.name {
font-size: 16px;
font-weight: bold;
color: #333;
}
.species {
font-size: 10px;
color: #666;
text-transform: uppercase;
}
.description {
font-size: 12px;
color: #555;
margin-bottom: 12px;
line-height: 1.4;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
background: #f5f5f5;
padding: 8px;
border-radius: 4px;
}
.stat-item {
display: flex;
justify-content: space-between;
font-size: 12px;
}
.label {
font-weight: bold;
color: #444;
}
.value {
color: #222;
}
.age-info {
margin-top: 8px;
text-align: right;
font-size: 10px;
color: #888;
}
</style>

View File

@ -6,6 +6,7 @@
<!-- 擲筊選項 --> <!-- 擲筊選項 -->
<button <button
class="prayer-option" class="prayer-option"
:class="{ active: selectedIndex === 0 }"
@click="$emit('select', 'jiaobei')" @click="$emit('select', 'jiaobei')"
> >
<div class="option-icon icon-jiaobei"></div> <div class="option-icon icon-jiaobei"></div>
@ -15,6 +16,7 @@
<!-- 求籤選項 --> <!-- 求籤選項 -->
<button <button
class="prayer-option" class="prayer-option"
:class="{ active: selectedIndex === 1 }"
@click="$emit('select', 'fortune')" @click="$emit('select', 'fortune')"
> >
<div class="option-icon icon-fortune"></div> <div class="option-icon icon-fortune"></div>
@ -23,13 +25,24 @@
</div> </div>
<!-- 返回按鈕 --> <!-- 返回按鈕 -->
<button class="back-button" @click="$emit('close')"> <button
class="back-button"
:class="{ active: selectedIndex === 2 }"
@click="$emit('close')"
>
返回 返回
</button> </button>
</div> </div>
</template> </template>
<script setup> <script setup>
defineProps({
selectedIndex: {
type: Number,
default: 0
}
});
defineEmits(['select', 'close']); defineEmits(['select', 'close']);
</script> </script>
@ -79,77 +92,15 @@ defineEmits(['select', 'close']);
min-width: 60px; /* 縮小最小寬度 */ min-width: 60px; /* 縮小最小寬度 */
} }
.prayer-option:hover { .prayer-option.active {
transform: translateY(-2px); background: #000;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); color: #fff;
border-color: #fff;
transform: scale(1.05);
} }
.prayer-option:active { .prayer-option.active .option-label {
transform: translateY(0); color: #fff;
}
.option-icon {
width: 24px; /* 縮小圖標 */
height: 24px;
position: relative;
transform: scale(0.8); /* 稍微縮小圖標內容 */
}
.option-label {
font-size: 10px; /* 縮小標籤 */
font-weight: bold;
color: #333;
font-family: monospace;
}
/* 擲筊圖標 - 可愛版(一對圓潤的紅筊) */
.icon-jiaobei::before {
content: '';
position: absolute;
width: 2px;
height: 2px;
background: transparent;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-shadow:
/* --- 左邊筊杯 (胖胖的月牙) --- */
-7px -4px 0 #ff5252, -5px -4px 0 #ff5252, -3px -4px 0 #ff5252,
-9px -2px 0 #ff5252, -7px -2px 0 #ff8a80, -5px -2px 0 #ff5252, -3px -2px 0 #ff5252, -1px -2px 0 #ff5252, /* #ff8a80 是高光 */
-9px 0px 0 #ff5252, -7px 0px 0 #ff5252, -5px 0px 0 #ff5252, -3px 0px 0 #ff5252, -1px 0px 0 #ff5252,
-7px 2px 0 #ff5252, -5px 2px 0 #ff5252, -3px 2px 0 #ff5252,
-5px 4px 0 #d32f2f, /* 陰影 */
/* --- 右邊筊杯 (對稱的胖月牙) --- */
3px -4px 0 #ff5252, 5px -4px 0 #ff5252, 7px -4px 0 #ff5252,
1px -2px 0 #ff5252, 3px -2px 0 #ff5252, 5px -2px 0 #ff5252, 7px -2px 0 #ff8a80, 9px -2px 0 #ff5252, /* 高光在右側 */
1px 0px 0 #ff5252, 3px 0px 0 #ff5252, 5px 0px 0 #ff5252, 7px 0px 0 #ff5252, 9px 0px 0 #ff5252,
3px 2px 0 #ff5252, 5px 2px 0 #ff5252, 7px 2px 0 #ff5252,
5px 4px 0 #d32f2f; /* 陰影 */
}
/* 求籤圖標 - 籤筒和籤條 */
.icon-fortune::before {
content: '';
position: absolute;
width: 2px;
height: 2px;
background: #8B4513; /* 木色 */
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
box-shadow:
/* 籤筒本體 */
-4px -4px 0 #8B4513, -2px -4px 0 #8B4513, 0px -4px 0 #8B4513, 2px -4px 0 #8B4513, 4px -4px 0 #8B4513,
-4px -2px 0 #8B4513, -2px -2px 0 #8B4513, 0px -2px 0 #8B4513, 2px -2px 0 #8B4513, 4px -2px 0 #8B4513,
-4px 0px 0 #8B4513, -2px 0px 0 #8B4513, 0px 0px 0 #8B4513, 2px 0px 0 #8B4513, 4px 0px 0 #8B4513,
-4px 2px 0 #8B4513, -2px 2px 0 #8B4513, 0px 2px 0 #8B4513, 2px 2px 0 #8B4513, 4px 2px 0 #8B4513,
-4px 4px 0 #8B4513, -2px 4px 0 #8B4513, 0px 4px 0 #8B4513, 2px 4px 0 #8B4513, 4px 4px 0 #8B4513,
/* 突出的籤條(紅色) */
-2px -8px 0 #d4522e, 0px -8px 0 #d4522e,
-2px -6px 0 #d4522e, 0px -6px 0 #d4522e,
2px -6px 0 #d4522e;
} }
.back-button { .back-button {
@ -164,11 +115,86 @@ defineEmits(['select', 'close']);
font-family: monospace; font-family: monospace;
} }
.back-button:hover { .back-button.active {
background: rgba(255, 255, 255, 1); background: #000;
color: #fff;
border-color: #fff;
} }
.back-button:active { /* --- Icons (Pixel Art) --- */
transform: translateY(1px); .option-icon {
width: 24px;
height: 24px;
position: relative;
margin-bottom: 4px;
} }
</style>
/* Jiaobei (Moon Blocks) - Two red crescents */
.icon-jiaobei::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 2px;
height: 2px;
background: #d4522e;
transform: translate(-8px, -2px); /* Left block */
box-shadow:
2px -2px 0 #d4522e, 4px -2px 0 #d4522e,
0px 0px 0 #d4522e, 2px 0px 0 #d4522e, 4px 0px 0 #d4522e, 6px 0px 0 #d4522e,
0px 2px 0 #d4522e, 2px 2px 0 #d4522e, 4px 2px 0 #d4522e,
2px 4px 0 #d4522e;
}
.icon-jiaobei::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 2px;
height: 2px;
background: #d4522e;
transform: translate(2px, -2px) scaleX(-1); /* Right block (mirrored) */
box-shadow:
2px -2px 0 #d4522e, 4px -2px 0 #d4522e,
0px 0px 0 #d4522e, 2px 0px 0 #d4522e, 4px 0px 0 #d4522e, 6px 0px 0 #d4522e,
0px 2px 0 #d4522e, 2px 2px 0 #d4522e, 4px 2px 0 #d4522e,
2px 4px 0 #d4522e;
}
/* Fortune (Stick Container) */
.icon-fortune::before {
content: '';
position: absolute;
bottom: 2px;
left: 50%;
width: 2px;
height: 2px;
background: #8b4513;
transform: translateX(-50%);
box-shadow:
/* Container Body */
-4px 0 0 #8b4513, -2px 0 0 #a0522d, 0 0 0 #a0522d, 2px 0 0 #a0522d, 4px 0 0 #8b4513,
-4px -2px 0 #8b4513, -2px -2px 0 #a0522d, 0 -2px 0 #a0522d, 2px -2px 0 #a0522d, 4px -2px 0 #8b4513,
-4px -4px 0 #8b4513, -2px -4px 0 #a0522d, 0 -4px 0 #a0522d, 2px -4px 0 #a0522d, 4px -4px 0 #8b4513,
-4px -6px 0 #8b4513, -2px -6px 0 #a0522d, 0 -6px 0 #a0522d, 2px -6px 0 #a0522d, 4px -6px 0 #8b4513,
-4px -8px 0 #8b4513, -2px -8px 0 #a0522d, 0 -8px 0 #a0522d, 2px -8px 0 #a0522d, 4px -8px 0 #8b4513,
/* Rim */
-6px -10px 0 #5c2e0e, -4px -10px 0 #8b4513, -2px -10px 0 #8b4513, 0 -10px 0 #8b4513, 2px -10px 0 #8b4513, 4px -10px 0 #8b4513, 6px -10px 0 #5c2e0e;
}
.icon-fortune::after {
content: '';
position: absolute;
bottom: 14px;
left: 50%;
width: 2px;
height: 2px;
background: #f0d09c;
transform: translateX(-50%);
box-shadow:
/* Sticks */
-2px 0 0 #f0d09c, 2px 0 0 #f0d09c,
-3px -2px 0 #f0d09c, 0px -2px 0 #f0d09c, 3px -2px 0 #f0d09c,
-1px -4px 0 #ff4444, 1px -4px 0 #ff4444; /* Red tips */
}</style>

View File

@ -1,9 +1,32 @@
<template> <template>
<div class="top-menu"> <div class="top-menu">
<button class="icon-btn icon-stats" @click="$emit('stats')" title="Stats"></button> <button
<button class="icon-btn icon-feed" @click="$emit('feed')" :disabled="disabled" title="Feed"></button> class="icon-btn icon-stats"
<button class="icon-btn icon-play" @click="$emit('play')" :disabled="disabled" title="Play"></button> :class="{ active: isMenuOpen && selectedIndex === 0 }"
<button class="icon-btn icon-sleep" @click="$emit('sleep')" :disabled="disabled" title="Sleep"></button> @click="$emit('stats')"
title="Stats"
></button>
<button
class="icon-btn icon-feed"
:class="{ active: isMenuOpen && selectedIndex === 1 }"
@click="$emit('feed')"
:disabled="disabled"
title="Feed"
></button>
<button
class="icon-btn icon-play"
:class="{ active: isMenuOpen && selectedIndex === 2 }"
@click="$emit('play')"
:disabled="disabled"
title="Play"
></button>
<button
class="icon-btn icon-sleep"
:class="{ active: isMenuOpen && selectedIndex === 3 }"
@click="$emit('sleep')"
:disabled="disabled"
title="Sleep"
></button>
</div> </div>
</template> </template>
@ -12,6 +35,14 @@ const props = defineProps({
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false default: false
},
isMenuOpen: {
type: Boolean,
default: false
},
selectedIndex: {
type: Number,
default: 0
} }
}); });
@ -36,10 +67,20 @@ defineEmits(['stats', 'feed', 'play', 'sleep']);
cursor: pointer; cursor: pointer;
position: relative; position: relative;
padding: 0; padding: 0;
opacity: 0.5; /* Default dim */
transition: opacity 0.2s, transform 0.2s;
}
.icon-btn.active {
opacity: 1;
transform: scale(1.1);
filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));
background: rgba(255, 255, 255, 0.5);
border-radius: 4px;
} }
.icon-btn:disabled { .icon-btn:disabled {
opacity: 0.3; opacity: 0.2;
cursor: not-allowed; cursor: not-allowed;
} }

View File

@ -1,47 +1,64 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'; import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { SPRITE_PRESETS } from '../data/petPresets.js';
const CONFIG = SPRITE_PRESETS.tinyTigerCatB;
export function usePetSystem() { export function usePetSystem() {
// --- State --- // --- State ---
const stage = ref('egg'); // egg, baby, adult const stage = ref('egg'); // egg, baby, child, adult
const state = ref('idle'); // idle, sleep, eating, sick, dead, refuse const state = ref('idle'); // idle, sleep, eating, sick, dead, refuse
// --- Stats --- // --- Stats ---
const stats = ref({ const stats = ref({
hunger: 100, // 0-100 (0 = Starving) hunger: CONFIG.needs.hunger.startValue,
happiness: 100, // 0-100 (0 = Depressed) happiness: CONFIG.needs.happiness.startValue,
health: 100, // 0-100 (0 = Sick risk) cleanliness: CONFIG.needs.cleanliness.startValue,
weight: 500, // grams energy: CONFIG.needs.energy.startValue,
age: 0, // days health: 100,
poopCount: 0 // Number of poops on screen poopCount: 0,
ageMinutes: 0, // Track age in minutes
// Tracking for evolution
hungerEvents: 0,
sicknessEvents: 0
}); });
// --- Internal Timers --- // --- Internal Timers ---
let gameLoopId = null; let gameLoopId = null;
const TICK_RATE = 3000; // 3 seconds per tick const TICK_RATE = 1000; // 1 second per tick for easier calculation
const SECONDS_PER_MINUTE = 60; // Game time scale (1 real sec = 1 game sec for now)
const isCleaning = ref(false); const isCleaning = ref(false);
// --- Computed Helpers ---
const currentStageConfig = computed(() => {
return CONFIG.lifecycle.stages.find(s => s.id === stage.value);
});
// --- Actions --- // --- Actions ---
function feed() { function feed() {
if (state.value === 'sleep' || state.value === 'dead' || stage.value === 'egg' || isCleaning.value) return false; if (state.value === 'sleep' || state.value === 'dead' || stage.value === 'egg' || isCleaning.value) return false;
// Check refusal (sick or full)
if (state.value === 'sick' || stats.value.hunger >= 90) { if (state.value === 'sick' || stats.value.hunger >= 90) {
// Refuse food if sick or full
triggerState('refuse', 2000); triggerState('refuse', 2000);
return false; return false;
} }
// Eat // Eat
triggerState('eating', 3000); // Animation duration triggerState('eating', 3000);
stats.value.hunger = Math.min(100, stats.value.hunger + 20);
stats.value.weight += 50;
// Chance to poop after eating (降低機率) // Recover hunger
if (Math.random() < 0.15) { // 從 0.3 降到 0.15 stats.value.hunger = Math.min(CONFIG.needs.hunger.max, stats.value.hunger + CONFIG.needs.hunger.feedRecover);
// Poop logic
// Simplified: Chance to poop based on feed count or random
if (Math.random() < (1 / CONFIG.needs.poop.feedsPerPoop)) {
setTimeout(() => { setTimeout(() => {
if (stats.value.poopCount < 4) { if (stats.value.poopCount < CONFIG.needs.poop.maxPoopOnScreen) {
stats.value.poopCount++; stats.value.poopCount++;
stats.value.cleanliness -= CONFIG.needs.cleanliness.decayPerPoop;
} }
}, 4000); }, 4000);
} }
@ -52,9 +69,9 @@ export function usePetSystem() {
function play() { function play() {
if (state.value !== 'idle' || stage.value === 'egg' || isCleaning.value) return false; if (state.value !== 'idle' || stage.value === 'egg' || isCleaning.value) return false;
stats.value.happiness = Math.min(100, stats.value.happiness + 15); stats.value.happiness = Math.min(CONFIG.needs.happiness.max, stats.value.happiness + CONFIG.needs.happiness.playRecover);
stats.value.weight -= 10; // Exercise burns calories stats.value.energy = Math.max(0, stats.value.energy - 5); // Playing tires you out
stats.value.hunger = Math.max(0, stats.value.hunger - 5); stats.value.hunger = Math.max(0, stats.value.hunger - 5); // Playing makes you hungry
return true; return true;
} }
@ -62,12 +79,12 @@ export function usePetSystem() {
if (stats.value.poopCount > 0 && !isCleaning.value) { if (stats.value.poopCount > 0 && !isCleaning.value) {
isCleaning.value = true; isCleaning.value = true;
// Delay removal for animation
setTimeout(() => { setTimeout(() => {
stats.value.poopCount = 0; stats.value.poopCount = 0;
stats.value.happiness += 10; stats.value.cleanliness = CONFIG.needs.cleanliness.max;
stats.value.happiness = Math.min(CONFIG.needs.happiness.max, stats.value.happiness + 10);
isCleaning.value = false; isCleaning.value = false;
}, 2000); // 2 seconds flush animation }, 2000);
return true; return true;
} }
@ -80,74 +97,116 @@ export function usePetSystem() {
if (state.value === 'idle') { if (state.value === 'idle') {
state.value = 'sleep'; state.value = 'sleep';
} else if (state.value === 'sleep') { } else if (state.value === 'sleep') {
state.value = 'idle'; // Wake up state.value = 'idle';
} }
} }
// --- Game Loop --- // --- Game Loop ---
function tick() { function tick() {
if (state.value === 'dead' || stage.value === 'egg') return; if (state.value === 'dead') return;
// Decrease stats naturally // 1. Age Growth
// 目標:飢餓值約 30-60 分鐘下降 10%,快樂值約 20-40 分鐘下降 10% // Add 1/60th of a minute (since tick is 1 sec)
// TICK_RATE = 3000ms (3秒), 600 ticks = 30分鐘 stats.value.ageMinutes += (TICK_RATE / 1000) / 60;
// 飢餓值每 tick -0.05 → 600 ticks = -30 (30分鐘下降30%)
// 快樂值每 tick -0.08 → 600 ticks = -48 (30分鐘下降48%) // Check Evolution
checkEvolution();
if (stage.value === 'egg') return; // Egg doesn't have needs decay
// 2. Needs Decay (Per Minute converted to Per Tick)
const decayFactor = (TICK_RATE / 1000) / 60; // Fraction of a minute passed
if (state.value !== 'sleep') { if (state.value !== 'sleep') {
stats.value.hunger = Math.max(0, stats.value.hunger - 0.05); stats.value.hunger = Math.max(0, stats.value.hunger - CONFIG.needs.hunger.decayPerMinute * decayFactor);
stats.value.happiness = Math.max(0, stats.value.happiness - 0.08); stats.value.happiness = Math.max(0, stats.value.happiness - CONFIG.needs.happiness.decayPerMinute * decayFactor);
stats.value.energy = Math.max(0, stats.value.energy - CONFIG.needs.energy.decayPerMinuteActive * decayFactor);
} else { } else {
// Slower decay when sleeping (約 1/3 速度) // Sleeping recovers energy, slower hunger decay
stats.value.hunger = Math.max(0, stats.value.hunger - 0.015); stats.value.energy = Math.min(CONFIG.needs.energy.max, stats.value.energy + CONFIG.needs.energy.recoverPerMinuteSleep * decayFactor);
stats.value.happiness = Math.max(0, stats.value.happiness - 0.025); stats.value.hunger = Math.max(0, stats.value.hunger - (CONFIG.needs.hunger.decayPerMinute * 0.5) * decayFactor);
} }
// Random poop generation (更低的機率:約 0.5% per tick) // 3. Health Logic
// 平均約每 200 ticks = 10 分鐘拉一次 // Poop penalty
if (state.value !== 'sleep' && Math.random() < 0.005 && stats.value.poopCount < 4 && !isCleaning.value) {
stats.value.poopCount++;
}
// Health Logic (更溫和的健康下降)
// 便便影響健康:每個便便每 tick -0.1 health
if (stats.value.poopCount > 0) { if (stats.value.poopCount > 0) {
stats.value.health = Math.max(0, stats.value.health - (0.1 * stats.value.poopCount)); stats.value.health = Math.max(0, stats.value.health - (0.1 * stats.value.poopCount));
} }
// 飢餓影響健康:飢餓值低於 20 時開始影響健康 // Starvation penalty
if (stats.value.hunger < 20) { if (stats.value.hunger < CONFIG.needs.hunger.criticalThreshold) {
const hungerPenalty = (20 - stats.value.hunger) * 0.02; // 飢餓越嚴重,扣越多 stats.value.health = Math.max(0, stats.value.health - 0.05);
stats.value.health = Math.max(0, stats.value.health - hungerPenalty); stats.value.hungerEvents++; // Track for evolution
} }
// 不開心影響健康:快樂值低於 20 時開始影響健康(較輕微) // Sickness Chance
if (stats.value.happiness < 20) { let sickChance = CONFIG.needs.sickness.baseChancePerMinute * decayFactor;
const happinessPenalty = (20 - stats.value.happiness) * 0.01; if (stats.value.cleanliness < CONFIG.needs.cleanliness.criticalThreshold) sickChance += CONFIG.needs.sickness.extraChanceIfDirty * decayFactor;
stats.value.health = Math.max(0, stats.value.health - happinessPenalty); if (stats.value.hunger < CONFIG.needs.hunger.criticalThreshold) sickChance += CONFIG.needs.sickness.extraChanceIfStarving * decayFactor;
}
// Sickness Check (更低的生病機率) if (Math.random() < sickChance && state.value !== 'sick') {
if (stats.value.health < 30 && state.value !== 'sick') {
if (Math.random() < 0.1) { // 從 0.3 降到 0.1
state.value = 'sick'; state.value = 'sick';
stats.value.sicknessEvents++;
}
// 4. Mood Update (Simplified)
updateMood();
// 5. Random Events
checkRandomEvents(decayFactor);
}
function checkEvolution() {
// Current stage max age check
if (currentStageConfig.value && currentStageConfig.value.maxAgeMinutes !== Infinity) {
if (stats.value.ageMinutes >= currentStageConfig.value.maxAgeMinutes) {
// Find next stage
// Simple linear progression for now, or use evolutionRules
const currentIndex = CONFIG.lifecycle.stages.findIndex(s => s.id === stage.value);
if (currentIndex < CONFIG.lifecycle.stages.length - 1) {
const nextStage = CONFIG.lifecycle.stages[currentIndex + 1];
evolveTo(nextStage.id);
}
}
} }
} }
// Health Recovery (健康值可以緩慢恢復) function evolveTo(newStageId) {
// 如果沒有便便、飢餓值和快樂值都高,健康值會緩慢恢復 console.log(`Evolving from ${stage.value} to ${newStageId}`);
if (stats.value.poopCount === 0 && stats.value.hunger > 50 && stats.value.happiness > 50 && stats.value.health < 100 && state.value !== 'sick') { stage.value = newStageId;
stats.value.health = Math.min(100, stats.value.health + 0.05); triggerState('idle', 1000); // Celebration?
} }
// Death Check (移除死亡機制,依照之前的討論) function updateMood() {
// if (stats.value.health === 0) { // Determine mood based on CONFIG.mood.states
// state.value = 'dead'; // Priority: Angry > Sad > Happy > Neutral
// } const m = CONFIG.mood.states;
// Evolution / Growth (Simple Age increment) // This is just internal state tracking, visual update happens in PetGame via props or events
// In a real game, 1 day might be 24h, here maybe every 100 ticks? // For now we just log or emit if needed
// For now, let's just say age increases slowly. }
function checkRandomEvents(decayFactor) {
CONFIG.randomEvents.forEach(event => {
const chance = event.chancePerMinute * decayFactor;
if (Math.random() < chance) {
// Check conditions
let conditionMet = true;
if (event.condition) {
if (event.condition.minEnergy && stats.value.energy < event.condition.minEnergy) conditionMet = false;
// Add other condition checks
}
if (conditionMet) {
console.log('Random Event:', event.message);
// Apply effects
if (event.effect) {
if (event.effect.happiness) stats.value.happiness += event.effect.happiness;
if (event.effect.energy) stats.value.energy += event.effect.energy;
}
}
}
});
} }
// --- Helpers --- // --- Helpers ---
@ -155,7 +214,7 @@ export function usePetSystem() {
const previousState = state.value; const previousState = state.value;
state.value = tempState; state.value = tempState;
setTimeout(() => { setTimeout(() => {
if (state.value === tempState) { // Only revert if state hasn't changed again if (state.value === tempState) {
state.value = previousState === 'sleep' ? 'idle' : 'idle'; state.value = previousState === 'sleep' ? 'idle' : 'idle';
} }
}, duration); }, duration);
@ -163,15 +222,14 @@ export function usePetSystem() {
function hatchEgg() { function hatchEgg() {
if (stage.value === 'egg') { if (stage.value === 'egg') {
stage.value = 'baby'; // or 'adult' for now since we only have that sprite // Force evolve to baby
// Let's map 'baby' to our 'adult' sprite for now, or just use 'adult' evolveTo('baby');
stage.value = 'adult';
state.value = 'idle'; // Reset stats for baby
stats.value.hunger = 50; stats.value.hunger = CONFIG.needs.hunger.startValue;
stats.value.happiness = 50; stats.value.happiness = CONFIG.needs.happiness.startValue;
stats.value.health = 100; stats.value.energy = CONFIG.needs.energy.startValue;
stats.value.poopCount = 0; stats.value.cleanliness = CONFIG.needs.cleanliness.startValue;
isCleaning.value = false;
} }
} }
@ -180,12 +238,15 @@ export function usePetSystem() {
state.value = 'idle'; state.value = 'idle';
isCleaning.value = false; isCleaning.value = false;
stats.value = { stats.value = {
hunger: 100, hunger: CONFIG.needs.hunger.startValue,
happiness: 100, happiness: CONFIG.needs.happiness.startValue,
cleanliness: CONFIG.needs.cleanliness.startValue,
energy: CONFIG.needs.energy.startValue,
health: 100, health: 100,
weight: 500, poopCount: 0,
age: 0, ageMinutes: 0,
poopCount: 0 hungerEvents: 0,
sicknessEvents: 0
}; };
} }

View File

@ -2,165 +2,123 @@
// 定義各種寵物的像素藝術數據 // 定義各種寵物的像素藝術數據
export const SPRITE_PRESETS = { export const SPRITE_PRESETS = {
tigerChick: {
name: 'tigerChick',
pixelSize: 3,
sprite: [
'1100000111000000',
'1241111331000000',
'1005102301000000',
'1054103320000000',
'1241143320100000',
'1230432311100110',
'1245321330100421',
'1240001311100111',
'1020103030111421',
'0100000331245210',
'0001111111240210',
'0015022221345210',
'0004022235350010',
'0011400023203210',
'0005011112115210',
'0001100001100110',
],
// 張嘴版修改嘴巴部分的像素row 8-9 是嘴巴區域)
spriteMouthOpen: [
'1100000111000000', // Row 0
'1241111331000000', // Row 1
'1005102301000000', // Row 2
'1054103320000000', // Row 3
'1241143320100000', // Row 4
'1230432311100110', // Row 5
'1245321330100421', // Row 6 - 保持不變
'1240001311100111', // Row 7 - 保持不變
'1000000030111421', // Row 8 - 嘴巴張開更往前移除位置2-7
'0000000331245210', // Row 9 - 嘴巴張開更往前移除位置1-7
'0001111111240210', // Row 10
'0015022221345210', // Row 11
'0004022235350010', // Row 12
'0011400023203210', // Row 13
'0005011112115210', // Row 14
'0001100001100110', // Row 15
],
palette: {
'1': '#2b2825',
'2': '#d0974b',
'3': '#e09037',
'4': '#4a2b0d',
'5': '#724e22',
},
tailPixels: [
[15, 8], [14, 8],
[15, 9], [14, 9],
[15, 10], [14, 10],
[15, 11], [14, 11],
],
legFrontPixels: [
[6, 13], [7, 13],
[6, 14], [7, 14],
],
legBackPixels: [
[9, 13], [10, 13],
[9, 14], [10, 14],
],
earPixels: [
[2, 0], [3, 0], [4, 0],
[11, 0], [12, 0], [13, 0],
],
blushPixels: [
[4, 7], [5, 7],
[10, 7], [11, 7],
],
iconBackLeft: { x: 3, y: 2 },
iconBackRight: { x: 12, y: 2 },
},
tinyTigerCat: {
name: '小虎斑貓',
pixelSize: 3,
sprite: [
'0000000000000000',
'0011000000110000', // row 1 - Ears
'0122111111221000', // row 2
'0122222222221000', // row 3
'0122322223221000', // row 4 - Stripes
'0122222222221000', // row 5
'0120022220021000', // row 6 - Eyes
'0122223322221000', // row 7 - Nose/Mouth
'0122222222221000', // row 8
'0011222222110000', // row 9 - Body
'0001222222121000', // row 10 - Body + Tail
'0001222222121000', // row 11
'0001100110110000', // row 12 - Legs
'0000000000000000', // row 13
'0000000000000000', // row 14
'0000000000000000', // row 15
],
spriteMouthOpen: [
'0000000000000000',
'0011000000110000',
'0122111111221000',
'0122222222221000',
'0122322223221000',
'0122222222221000',
'0120022220021000',
'0122223322221000',
'0122200002221000', // Mouth Open
'0011222222110000',
'0001222222121000',
'0001222222121000',
'0001100110110000',
'0000000000000000',
'0000000000000000',
'0000000000000000',
],
palette: {
'0': '#000000', // Black eyes/outline
'1': '#2b1d12', // Dark brown outline
'2': '#ffb347', // Orange fur
'3': '#cd853f', // Darker stripes/nose
},
tailPixels: [
[11, 10], [12, 10],
[11, 11], [12, 11],
],
earPixels: [
[2, 1], [3, 1],
[10, 1], [11, 1],
],
legFrontPixels: [
[4, 12], [5, 12],
],
legBackPixels: [
[8, 12], [9, 12],
],
blushPixels: [
[3, 7], [10, 7]
],
iconBackLeft: { x: 2, y: 2 },
iconBackRight: { x: 11, y: 2 }
},
tinyTigerCatB: { tinyTigerCatB: {
id: 'tinyTigerCatB',
meta: {
name: '小虎斑貓', name: '小虎斑貓',
pixelSize: 3, displayNameEn: 'Tiny Tiger Cat',
sprite: [ species: 'cat',
'0000000000000000', element: 'normal',
'0011000000110000', // row 1 - Ears description: '一隻活潑、黏人的小虎斑貓,喜歡被餵食和玩耍。'
'0124444111442100', // row 2 粉紅耳朵內側 },
'0123222323221000', // row 3 三條虎紋 lifecycle: {
'0122322223221000', // row 4 - Stripes baseLifeMinutes: 7 * 24 * 60,
'0122522222522100', // row 5 眼睛反光 stages: [
'0125052225052100', // row 6 大圓眼+黑瞳孔+白反光 {
'0112223322221100', // row 7 鼻子+左右鬍鬚 id: 'egg',
'0122220222221000', // row 8 小微笑 name: '蛋',
'0011222222110000', // row 9 - Body minAgeMinutes: 0,
'0001222222121000', // row 10 - Body + Tail maxAgeMinutes: 30,
'0001222222121000', // row 11 spriteKey: 'egg',
'0001100110110000', // row 12 - Legs canBattle: false,
'0000000000000000', // row 13 canEquip: false
'0000000000000000', // row 14 },
'0000000000000000', // row 15 {
id: 'baby',
name: '幼年期',
minAgeMinutes: 30,
maxAgeMinutes: 6 * 60,
spriteKey: 'child',
canBattle: false,
canEquip: false
},
{
id: 'child',
name: '成長期',
minAgeMinutes: 6 * 60,
maxAgeMinutes: 24 * 60,
spriteKey: 'child',
canBattle: true,
canEquip: true
},
{
id: 'adult',
name: '成熟期',
minAgeMinutes: 24 * 60,
maxAgeMinutes: Infinity,
spriteKey: 'adult',
canBattle: true,
canEquip: true
}
], ],
spriteMouthOpen: [ evolutionRules: [
{
fromStage: 'baby',
toStage: 'child',
condition: {
maxHungerEvents: 5,
maxSicknessEvents: 2
}
}
]
},
needs: {
hunger: {
max: 100,
startValue: 70,
decayPerMinute: 2,
warnThreshold: 40,
criticalThreshold: 10,
feedRecover: 40
},
happiness: {
max: 100,
startValue: 60,
decayPerMinute: 1,
playRecover: 25,
lowThreshold: 30
},
cleanliness: {
max: 100,
startValue: 80,
decayPerPoop: 30,
criticalThreshold: 30
},
energy: {
max: 100,
startValue: 80,
decayPerMinuteActive: 2,
recoverPerMinuteSleep: 5,
sleepSuggestThreshold: 30
},
poop: {
feedsPerPoop: 3,
maxPoopOnScreen: 3
},
sickness: {
baseChancePerMinute: 0.001,
extraChanceIfDirty: 0.01,
extraChanceIfStarving: 0.02
}
},
stats: {
base: {
hp: 30,
attack: 8,
defense: 5,
speed: 7
},
stageModifiers: {
baby: { hp: 0.6, attack: 0.5, defense: 0.5, speed: 0.8 },
child: { hp: 1.0, attack: 1.0, defense: 1.0, speed: 1.0 },
adult: { hp: 1.4, attack: 1.3, defense: 1.2, speed: 1.1 }
}
},
appearance: {
pixelSize: 3,
sprites: {
child: {
idle: [
'0000000000000000', '0000000000000000',
'0011000000110000', '0011000000110000',
'0124444111442100', '0124444111442100',
@ -169,7 +127,7 @@ export const SPRITE_PRESETS = {
'0122522222522100', '0122522222522100',
'0125052225052100', '0125052225052100',
'0112223322221100', '0112223322221100',
'0122204002221000', // Mouth Open 粉紅舌頭 '0122220222221000',
'0011222222110000', '0011222222110000',
'0001222222121000', '0001222222121000',
'0001222222121000', '0001222222121000',
@ -178,14 +136,100 @@ export const SPRITE_PRESETS = {
'0000000000000000', '0000000000000000',
'0000000000000000', '0000000000000000',
], ],
palette: { mouthOpen: [
'0': '#000000', // Black eyes/outline '0000000000000000',
'1': '#2b1d12', // Dark brown outline '0011000000110000',
'2': '#ffb347', // Orange fur '0124444111442100',
'3': '#cd853f', // Darker stripes/nose '0123222323221000',
'4': '#ffb6c1', // Pink (ears, blush, tongue) '0122322223221000',
'5': '#ffffff' // White eye highlight '0122522222522100',
'0125052225052100',
'0112223322221100',
'0122204002221000',
'0011222222110000',
'0001222222121000',
'0001222222121000',
'0001100110110000',
'0000000000000000',
'0000000000000000',
'0000000000000000',
],
eyesClosed: [
'0000000000000000',
'0011000000110000',
'0124444111442100',
'0123222323221000',
'0122322223221000',
'0122522222522100',
'0122222222222100',
'0112223322221100',
'0122220222221000',
'0011222222110000',
'0001222222121000',
'0001222222121000',
'0001100110110000',
'0000000000000000',
'0000000000000000',
'0000000000000000',
]
}, },
egg: {
idle: [
'0000000000000000',
'0000000000000000',
'0000000111000000',
'0000001222100000',
'0000012232210000',
'0000122333221000',
'0000122232221000',
'0001222222222100',
'0001233322332100',
'0001223222232100',
'0000122222221000',
'0000122222221000',
'0000011222110000',
'0000000111000000',
'0000000000000000',
'0000000000000000',
]
},
adult: {
idle: [
'0000000000000000',
'0011000000110000',
'0124444111442100',
'0123222323221000',
'0122322223221000',
'0122522222522100',
'0125052225052100',
'0112223322221100',
'0122220222221000',
'0011222222110000',
'0001222222121000',
'0001222222121000',
'0001100110110000',
'0000000000000000',
'0000000000000000',
'0000000000000000',
]
}
},
palettes: {
default: {
'0': '#000000',
'1': '#2b1d12',
'2': '#ffb347',
'3': '#cd853f',
'4': '#ffb6c1',
'5': '#ffffff'
},
egg: {
'1': '#5d4037',
'2': '#fff8e1',
'3': '#ffb74d',
}
},
bodyParts: {
tailPixels: [ tailPixels: [
[11, 10], [12, 10], [11, 10], [12, 10],
[11, 11], [12, 11], [11, 11], [12, 11],
@ -204,53 +248,77 @@ export const SPRITE_PRESETS = {
[3, 7], [10, 7] [3, 7], [10, 7]
], ],
eyePixels: [ eyePixels: [
[3, 6], [4, 6], // Left eye [3, 6], [4, 6],
[8, 6], [9, 6] // Right eye [8, 6], [9, 6]
],
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 }, iconBackLeft: { x: 2, y: 2 },
iconBackRight: { x: 13, y: 2 }, iconBackRight: { x: 13, y: 2 }
},
// Growth Stages behaviorAnimation: {
eggSprite: [ blinkIntervalSec: 5,
'0000000000000000', blinkDurationMs: 200,
'0000000000000000', mouthOpenDurationMs: 300,
'0000000111000000', // Top (Narrow) idleEmoteIntervalSec: 15
'0000001222100000',
'0000012232210000', // Small stripe
'0000122333221000',
'0000122232221000',
'0001222222222100', // Widest part
'0001233322332100', // Side stripes
'0001223222232100',
'0000122222221000',
'0000122222221000',
'0000011222110000', // Bottom
'0000000111000000',
'0000000000000000',
'0000000000000000',
],
eggPalette: {
'1': '#5d4037', // Dark brown outline
'2': '#fff8e1', // Creamy white shell
'3': '#ffb74d', // Orange tiger stripes
} }
},
equipment: {
slots: ['head', 'face', 'neck', 'back'],
items: [
{
id: 'sunglasses_basic',
name: '基本墨鏡',
slot: 'face',
overlays: {
child: {
pixels: [
{ x: 3, y: 6, color: '0' },
{ x: 4, y: 6, color: '0' },
{ x: 8, y: 6, color: '0' },
{ x: 9, y: 6, color: '0' },
]
}
},
statModifiers: {
coolness: +10
}
}
]
},
personality: {
traits: ['clingy', 'energetic'],
modifiers: {
hungerDecay: 1.0,
happinessDecay: 1.2,
energyDecay: 0.9
}
},
mood: {
system: {
updateIntervalMinutes: 10,
factors: ['happiness', 'hunger', 'health']
},
states: {
happy: { minHappiness: 80, spriteFace: 'happy' },
neutral: { minHappiness: 40, spriteFace: 'neutral' },
sad: { maxHappiness: 40, spriteFace: 'sad' },
angry: { maxHunger: 20, spriteFace: 'angry' }
}
},
randomEvents: [
{
id: 'trip_over',
chancePerMinute: 0.005,
condition: { maxCoordination: 20 },
effect: { happiness: -5, health: -1 },
message: 'Tripped and fell!'
},
{
id: 'zoomies',
chancePerMinute: 0.01,
condition: { minEnergy: 90 },
effect: { energy: -20, happiness: +10 },
message: 'Suddenly started running around!'
}
]
} }
}; };