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 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
const {
@ -24,8 +33,48 @@ const {
reset
} = 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
function handleAction(action) {
console.log('Action triggered:', action);
switch(action) {
case 'feed':
const feedResult = feed();
@ -51,8 +100,25 @@ function handleAction(action) {
}
break;
case 'stats':
// Toggle stats display
showStats.value = !showStats.value;
// Toggle stats mode: none -> bars -> details -> none
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;
case 'settings':
// Show reset options
@ -62,16 +128,6 @@ function handleAction(action) {
reset();
}
break;
case 'jiaobei':
// -
console.log('擲筊功能');
// TODO:
break;
case 'fortune':
// -
console.log('求籤功能');
// TODO:
break;
default:
console.log('Action not implemented:', action);
}
@ -84,7 +140,7 @@ function setPetState(newState) {
</script>
<template>
<DeviceShell>
<DeviceShell @btn1="handleButton(1)" @btn2="handleButton(2)" @btn3="handleButton(3)">
<DeviceScreen>
<!-- Dynamic Component Switching -->
<PetGame
@ -94,7 +150,9 @@ function setPetState(newState) {
:stage="stage"
:stats="stats"
:isCleaning="isCleaning"
:showStats="showStats"
:statsMode="statsMode"
:isMenuOpen="isMenuOpen"
:selectedMenuIndex="selectedMenuIndex"
@update:state="state = $event"
@action="handleAction"
/>

View File

@ -1,9 +1,33 @@
<template>
<div class="action-menu">
<button class="icon-btn icon-clean" @click="$emit('clean')" :disabled="disabled || poopCount === 0" title="清理"></button>
<button class="icon-btn icon-medicine" @click="$emit('medicine')" :disabled="disabled || !isSick" title="治療"></button>
<button class="icon-btn icon-training" @click="$emit('training')" :disabled="disabled" title="祈禱"></button>
<button class="icon-btn icon-info" @click="$emit('info')" :disabled="disabled" title="資訊"></button>
<button
class="icon-btn icon-clean"
:class="{ active: isMenuOpen && selectedIndex === 4 }"
@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>
</template>
@ -24,6 +48,14 @@ const props = defineProps({
isSick: {
type: Boolean,
default: false
},
isMenuOpen: {
type: Boolean,
default: false
},
selectedIndex: {
type: Number,
default: 0
}
});
@ -48,10 +80,20 @@ defineEmits(['clean', 'medicine', 'training', 'info']);
cursor: pointer;
position: relative;
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 {
opacity: 0.3;
opacity: 0.2;
cursor: not-allowed;
}

View File

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

View File

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

View File

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

View File

@ -3,6 +3,8 @@
<!-- Top Menu -->
<TopMenu
:disabled="stage === 'egg'"
:isMenuOpen="isMenuOpen"
:selectedIndex="selectedMenuIndex"
@stats="$emit('action', 'stats')"
@feed="$emit('action', 'feed')"
@play="$emit('action', 'play')"
@ -11,11 +13,17 @@
<!-- Stats Dashboard (Toggelable) -->
<StatsBar
v-if="showStats"
v-if="statsMode === 'bars'"
:hunger="stats?.hunger || 100"
:happiness="stats?.happiness || 100"
:health="stats?.health || 100"
/>
<!-- Detailed Info Overlay -->
<PetInfo
v-if="statsMode === 'details'"
:stats="stats"
/>
<!-- Game Area (Center) -->
<div class="pet-game-container" ref="containerRef">
@ -133,6 +141,7 @@
<!-- Prayer Menu (覆蓋整個遊戲區域) -->
<PrayerMenu
v-if="showPrayerMenu"
:selectedIndex="prayerMenuIndex"
@select="handlePrayerSelect"
@close="showPrayerMenu = false"
/>
@ -140,8 +149,10 @@
<!-- Jiaobei Animation (覆蓋遊戲區域) -->
<JiaobeiAnimation
v-if="showJiaobeiAnimation"
ref="jiaobeiRef"
:mode="fortuneMode"
:consecutiveCount="consecutiveSaintCount"
:selectedIndex="jiaobeiIndex"
@close="handleJiaobeiClose"
@result="handleJiaobeiResult"
@retry-fortune="handleRetryFortune"
@ -151,6 +162,8 @@
<!-- Fortune Stick Animation -->
<FortuneStickAnimation
v-if="showFortuneStick"
ref="fortuneStickRef"
:selectedIndex="fortuneStickIndex"
@complete="handleStickComplete"
@close="handleFortuneStickClose"
/>
@ -168,6 +181,8 @@
:poopCount="stats?.poopCount || 0"
:health="stats?.health || 100"
:isSick="state === 'sick'"
:isMenuOpen="isMenuOpen"
:selectedIndex="selectedMenuIndex"
@clean="$emit('action', 'clean')"
@medicine="$emit('action', 'medicine')"
@training="showPrayerMenu = true"
@ -182,6 +197,7 @@ 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 PetInfo from './PetInfo.vue';
import ActionMenu from './ActionMenu.vue';
import TopMenu from './TopMenu.vue';
import PrayerMenu from './PrayerMenu.vue';
@ -203,13 +219,21 @@ const props = defineProps({
type: Object,
default: () => ({ hunger: 100, happiness: 100, health: 100, poopCount: 0 })
},
showStats: {
type: Boolean,
default: false
statsMode: {
type: String,
default: 'none' // 'none', 'bars', 'details'
},
isCleaning: {
type: Boolean,
default: false
},
isMenuOpen: {
type: Boolean,
default: false
},
selectedMenuIndex: {
type: Number,
default: 0
}
});
@ -264,16 +288,7 @@ function handleFortuneStickClose() {
consecutiveSaintCount.value = 0;
}
function handleJiaobeiResult(type) {
if (fortuneMode.value === 'fortune') {
if (type === 'saint') {
consecutiveSaintCount.value++;
} else {
//
consecutiveSaintCount.value = 0;
}
}
}
function handleRetryFortune() {
//
@ -379,11 +394,38 @@ const FOOD_PALETTE = FOOD_OPTIONS[currentFood].palette;
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
const rows = CURRENT_PRESET.sprite.length;
const cols = CURRENT_PRESET.sprite[0].length;
// Helper to get current stage config
const currentStageConfig = computed(() => {
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 height = rows * pixelSize;
@ -391,7 +433,9 @@ const height = rows * pixelSize;
// 2. Generate Pixels Helper
function generatePixels(spriteMap, paletteOverride = null) {
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) => {
[...rowStr].forEach((ch, x) => {
@ -400,11 +444,14 @@ function generatePixels(spriteMap, paletteOverride = null) {
// Only apply body part classes if NOT an egg
let className = '';
if (props.stage !== 'egg') {
const isTail = CURRENT_PRESET.tailPixels?.some(([tx, ty]) => tx === x && ty === y);
const isLegFront = CURRENT_PRESET.legFrontPixels?.some(([lx, ly]) => lx === x && ly === y);
const isLegBack = CURRENT_PRESET.legBackPixels?.some(([lx, ly]) => lx === x && ly === y);
const isEar = CURRENT_PRESET.earPixels?.some(([ex, ey]) => ex === x && ey === y);
const isBlush = CURRENT_PRESET.blushPixels?.some(([bx, by]) => bx === x && by === y);
// Handle new bodyParts structure vs old root properties
const bodyParts = CURRENT_PRESET.appearance?.bodyParts || CURRENT_PRESET;
const isTail = bodyParts.tailPixels?.some(([tx, ty]) => tx === x && ty === 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 (isLegFront) className += ' leg-front';
@ -447,12 +494,33 @@ const foodVisible = ref(false);
const isBlinking = ref(false);
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') {
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);
}
@ -484,7 +552,11 @@ const iconStyle = computed(() => ({
}));
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 baseX = petX.value + marker.x * pixelSize;
const baseY = petY.value + marker.y * pixelSize;
@ -786,9 +858,129 @@ async function shakeHead() {
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({
shakeHead
shakeHead,
openPrayerMenu,
handleInput
});
</script>
@ -798,6 +990,7 @@ defineExpose({
flex-direction: column;
width: 100%;
height: 100%;
position: relative;
}
.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
class="prayer-option"
:class="{ active: selectedIndex === 0 }"
@click="$emit('select', 'jiaobei')"
>
<div class="option-icon icon-jiaobei"></div>
@ -15,6 +16,7 @@
<!-- 求籤選項 -->
<button
class="prayer-option"
:class="{ active: selectedIndex === 1 }"
@click="$emit('select', 'fortune')"
>
<div class="option-icon icon-fortune"></div>
@ -23,13 +25,24 @@
</div>
<!-- 返回按鈕 -->
<button class="back-button" @click="$emit('close')">
<button
class="back-button"
:class="{ active: selectedIndex === 2 }"
@click="$emit('close')"
>
返回
</button>
</div>
</template>
<script setup>
defineProps({
selectedIndex: {
type: Number,
default: 0
}
});
defineEmits(['select', 'close']);
</script>
@ -79,77 +92,15 @@ defineEmits(['select', 'close']);
min-width: 60px; /* 縮小最小寬度 */
}
.prayer-option:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
.prayer-option.active {
background: #000;
color: #fff;
border-color: #fff;
transform: scale(1.05);
}
.prayer-option:active {
transform: translateY(0);
}
.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;
.prayer-option.active .option-label {
color: #fff;
}
.back-button {
@ -164,11 +115,86 @@ defineEmits(['select', 'close']);
font-family: monospace;
}
.back-button:hover {
background: rgba(255, 255, 255, 1);
.back-button.active {
background: #000;
color: #fff;
border-color: #fff;
}
.back-button:active {
transform: translateY(1px);
/* --- Icons (Pixel Art) --- */
.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>
<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>
<button
class="icon-btn icon-stats"
:class="{ active: isMenuOpen && selectedIndex === 0 }"
@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>
</template>
@ -12,6 +35,14 @@ const props = defineProps({
disabled: {
type: Boolean,
default: false
},
isMenuOpen: {
type: Boolean,
default: false
},
selectedIndex: {
type: Number,
default: 0
}
});
@ -36,10 +67,20 @@ defineEmits(['stats', 'feed', 'play', 'sleep']);
cursor: pointer;
position: relative;
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 {
opacity: 0.3;
opacity: 0.2;
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() {
// --- 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
// --- 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
hunger: CONFIG.needs.hunger.startValue,
happiness: CONFIG.needs.happiness.startValue,
cleanliness: CONFIG.needs.cleanliness.startValue,
energy: CONFIG.needs.energy.startValue,
health: 100,
poopCount: 0,
ageMinutes: 0, // Track age in minutes
// Tracking for evolution
hungerEvents: 0,
sicknessEvents: 0
});
// --- Internal Timers ---
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);
// --- Computed Helpers ---
const currentStageConfig = computed(() => {
return CONFIG.lifecycle.stages.find(s => s.id === stage.value);
});
// --- Actions ---
function feed() {
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) {
// 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;
triggerState('eating', 3000);
// Chance to poop after eating (降低機率)
if (Math.random() < 0.15) { // 從 0.3 降到 0.15
// Recover hunger
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(() => {
if (stats.value.poopCount < 4) {
if (stats.value.poopCount < CONFIG.needs.poop.maxPoopOnScreen) {
stats.value.poopCount++;
stats.value.cleanliness -= CONFIG.needs.cleanliness.decayPerPoop;
}
}, 4000);
}
@ -52,9 +69,9 @@ export function usePetSystem() {
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);
stats.value.happiness = Math.min(CONFIG.needs.happiness.max, stats.value.happiness + CONFIG.needs.happiness.playRecover);
stats.value.energy = Math.max(0, stats.value.energy - 5); // Playing tires you out
stats.value.hunger = Math.max(0, stats.value.hunger - 5); // Playing makes you hungry
return true;
}
@ -62,12 +79,12 @@ export function usePetSystem() {
if (stats.value.poopCount > 0 && !isCleaning.value) {
isCleaning.value = true;
// Delay removal for animation
setTimeout(() => {
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;
}, 2000); // 2 seconds flush animation
}, 2000);
return true;
}
@ -80,74 +97,116 @@ export function usePetSystem() {
if (state.value === 'idle') {
state.value = 'sleep';
} else if (state.value === 'sleep') {
state.value = 'idle'; // Wake up
state.value = 'idle';
}
}
// --- Game Loop ---
function tick() {
if (state.value === 'dead' || stage.value === 'egg') return;
if (state.value === 'dead') return;
// Decrease stats naturally
// 目標:飢餓值約 30-60 分鐘下降 10%,快樂值約 20-40 分鐘下降 10%
// TICK_RATE = 3000ms (3秒), 600 ticks = 30分鐘
// 飢餓值每 tick -0.05 → 600 ticks = -30 (30分鐘下降30%)
// 快樂值每 tick -0.08 → 600 ticks = -48 (30分鐘下降48%)
// 1. Age Growth
// Add 1/60th of a minute (since tick is 1 sec)
stats.value.ageMinutes += (TICK_RATE / 1000) / 60;
// 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') {
stats.value.hunger = Math.max(0, stats.value.hunger - 0.05);
stats.value.happiness = Math.max(0, stats.value.happiness - 0.08);
stats.value.hunger = Math.max(0, stats.value.hunger - CONFIG.needs.hunger.decayPerMinute * decayFactor);
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 {
// Slower decay when sleeping (約 1/3 速度)
stats.value.hunger = Math.max(0, stats.value.hunger - 0.015);
stats.value.happiness = Math.max(0, stats.value.happiness - 0.025);
// Sleeping recovers energy, slower hunger decay
stats.value.energy = Math.min(CONFIG.needs.energy.max, stats.value.energy + CONFIG.needs.energy.recoverPerMinuteSleep * decayFactor);
stats.value.hunger = Math.max(0, stats.value.hunger - (CONFIG.needs.hunger.decayPerMinute * 0.5) * decayFactor);
}
// Random poop generation (更低的機率:約 0.5% per tick)
// 平均約每 200 ticks = 10 分鐘拉一次
if (state.value !== 'sleep' && Math.random() < 0.005 && stats.value.poopCount < 4 && !isCleaning.value) {
stats.value.poopCount++;
}
// Health Logic (更溫和的健康下降)
// 便便影響健康:每個便便每 tick -0.1 health
// 3. Health Logic
// Poop penalty
if (stats.value.poopCount > 0) {
stats.value.health = Math.max(0, stats.value.health - (0.1 * stats.value.poopCount));
}
// 飢餓影響健康:飢餓值低於 20 時開始影響健康
if (stats.value.hunger < 20) {
const hungerPenalty = (20 - stats.value.hunger) * 0.02; // 飢餓越嚴重,扣越多
stats.value.health = Math.max(0, stats.value.health - hungerPenalty);
// Starvation penalty
if (stats.value.hunger < CONFIG.needs.hunger.criticalThreshold) {
stats.value.health = Math.max(0, stats.value.health - 0.05);
stats.value.hungerEvents++; // Track for evolution
}
// 不開心影響健康:快樂值低於 20 時開始影響健康(較輕微)
if (stats.value.happiness < 20) {
const happinessPenalty = (20 - stats.value.happiness) * 0.01;
stats.value.health = Math.max(0, stats.value.health - happinessPenalty);
// Sickness Chance
let sickChance = CONFIG.needs.sickness.baseChancePerMinute * decayFactor;
if (stats.value.cleanliness < CONFIG.needs.cleanliness.criticalThreshold) sickChance += CONFIG.needs.sickness.extraChanceIfDirty * decayFactor;
if (stats.value.hunger < CONFIG.needs.hunger.criticalThreshold) sickChance += CONFIG.needs.sickness.extraChanceIfStarving * decayFactor;
if (Math.random() < sickChance && state.value !== 'sick') {
state.value = 'sick';
stats.value.sicknessEvents++;
}
// Sickness Check (更低的生病機率)
if (stats.value.health < 30 && state.value !== 'sick') {
if (Math.random() < 0.1) { // 從 0.3 降到 0.1
state.value = 'sick';
// 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 (健康值可以緩慢恢復)
// 如果沒有便便、飢餓值和快樂值都高,健康值會緩慢恢復
if (stats.value.poopCount === 0 && stats.value.hunger > 50 && stats.value.happiness > 50 && stats.value.health < 100 && state.value !== 'sick') {
stats.value.health = Math.min(100, stats.value.health + 0.05);
}
function evolveTo(newStageId) {
console.log(`Evolving from ${stage.value} to ${newStageId}`);
stage.value = newStageId;
triggerState('idle', 1000); // Celebration?
}
// Death Check (移除死亡機制,依照之前的討論)
// if (stats.value.health === 0) {
// state.value = 'dead';
// }
function updateMood() {
// Determine mood based on CONFIG.mood.states
// Priority: Angry > Sad > Happy > Neutral
const m = CONFIG.mood.states;
// 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.
// This is just internal state tracking, visual update happens in PetGame via props or events
// For now we just log or emit if needed
}
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 ---
@ -155,7 +214,7 @@ export function usePetSystem() {
const previousState = state.value;
state.value = tempState;
setTimeout(() => {
if (state.value === tempState) { // Only revert if state hasn't changed again
if (state.value === tempState) {
state.value = previousState === 'sleep' ? 'idle' : 'idle';
}
}, duration);
@ -163,15 +222,14 @@ export function usePetSystem() {
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;
// Force evolve to baby
evolveTo('baby');
// Reset stats for baby
stats.value.hunger = CONFIG.needs.hunger.startValue;
stats.value.happiness = CONFIG.needs.happiness.startValue;
stats.value.energy = CONFIG.needs.energy.startValue;
stats.value.cleanliness = CONFIG.needs.cleanliness.startValue;
}
}
@ -180,12 +238,15 @@ export function usePetSystem() {
state.value = 'idle';
isCleaning.value = false;
stats.value = {
hunger: 100,
happiness: 100,
hunger: CONFIG.needs.hunger.startValue,
happiness: CONFIG.needs.happiness.startValue,
cleanliness: CONFIG.needs.cleanliness.startValue,
energy: CONFIG.needs.energy.startValue,
health: 100,
weight: 500,
age: 0,
poopCount: 0
poopCount: 0,
ageMinutes: 0,
hungerEvents: 0,
sicknessEvents: 0
};
}

View File

@ -2,255 +2,323 @@
// 定義各種寵物的像素藝術數據
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: {
name: '小虎斑貓',
pixelSize: 3,
sprite: [
'0000000000000000',
'0011000000110000', // row 1 - Ears
'0124444111442100', // row 2 粉紅耳朵內側
'0123222323221000', // row 3 三條虎紋
'0122322223221000', // row 4 - Stripes
'0122522222522100', // row 5 眼睛反光
'0125052225052100', // row 6 大圓眼+黑瞳孔+白反光
'0112223322221100', // row 7 鼻子+左右鬍鬚
'0122220222221000', // 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',
'0124444111442100',
'0123222323221000',
'0122322223221000',
'0122522222522100',
'0125052225052100',
'0112223322221100',
'0122204002221000', // 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
'4': '#ffb6c1', // Pink (ears, blush, tongue)
'5': '#ffffff' // White eye highlight
id: 'tinyTigerCatB',
meta: {
name: '小虎斑貓',
displayNameEn: 'Tiny Tiger Cat',
species: 'cat',
element: 'normal',
description: '一隻活潑、黏人的小虎斑貓,喜歡被餵食和玩耍。'
},
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]
],
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 },
// Growth Stages
eggSprite: [
'0000000000000000',
'0000000000000000',
'0000000111000000', // Top (Narrow)
'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
}
lifecycle: {
baseLifeMinutes: 7 * 24 * 60,
stages: [
{
id: 'egg',
name: '蛋',
minAgeMinutes: 0,
maxAgeMinutes: 30,
spriteKey: 'egg',
canBattle: false,
canEquip: false
},
{
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
}
],
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',
'0011000000110000',
'0124444111442100',
'0123222323221000',
'0122322223221000',
'0122522222522100',
'0125052225052100',
'0112223322221100',
'0122220222221000',
'0011222222110000',
'0001222222121000',
'0001222222121000',
'0001100110110000',
'0000000000000000',
'0000000000000000',
'0000000000000000',
],
mouthOpen: [
'0000000000000000',
'0011000000110000',
'0124444111442100',
'0123222323221000',
'0122322223221000',
'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: [
[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]
],
eyePixels: [
[3, 6], [4, 6],
[8, 6], [9, 6]
],
iconBackLeft: { x: 2, y: 2 },
iconBackRight: { x: 13, y: 2 }
},
behaviorAnimation: {
blinkIntervalSec: 5,
blinkDurationMs: 200,
mouthOpenDurationMs: 300,
idleEmoteIntervalSec: 15
}
},
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!'
}
]
}
};