good version

This commit is contained in:
王性驊 2025-11-22 20:08:15 +08:00
parent 11c01fb1ea
commit 1c068b1672
3 changed files with 737 additions and 3 deletions

307
src/components/BallGame.vue Normal file
View File

@ -0,0 +1,307 @@
<template>
<div class="ball-game-layer" @touchstart="handleTouch" @mousedown="handleMouse">
<canvas ref="gameCanvas" class="game-canvas"></canvas>
<div class="game-ui">
<div class="score">接球: {{ score }}/10</div>
<div v-if="!gameStarted" class="start-hint">
<p>點擊左右移動接球</p>
<p>點擊螢幕開始</p>
</div>
<div v-if="gameOver" class="game-over-msg">
<p>{{ win ? '大成功!' : '再試一次' }}</p>
<button @click.stop="handleClose">完成</button>
</div>
</div>
<!-- 觸控區域提示 (僅在開始前顯示) -->
<div v-if="!gameStarted" class="touch-zones">
<div class="zone left"> </div>
<div class="zone right"> </div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const emit = defineEmits(['close', 'complete', 'updatePetX']);
const gameCanvas = ref(null);
const gameStarted = ref(false);
const gameOver = ref(false);
const win = ref(false);
const score = ref(0);
const targetScore = 10;
//
let balls = [];
let animationId = null;
let ctx = null;
let canvasWidth = 300;
let canvasHeight = 150;
let lastTime = 0;
let spawnTimer = 0;
// ( PetGame )
let petX = 150;
const PET_Y = 100; //
const PET_WIDTH = 32;
const PET_SPEED = 15; //
function initGame() {
if (!gameCanvas.value) return;
const canvas = gameCanvas.value;
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
canvasWidth = rect.width;
canvasHeight = rect.height;
//
petX = canvasWidth / 2 - PET_WIDTH / 2;
emit('updatePetX', petX);
ctx = canvas.getContext('2d');
loop(0);
}
function startGame() {
gameStarted.value = true;
gameOver.value = false;
score.value = 0;
balls = [];
lastTime = performance.now();
}
function handleTouch(e) {
if (gameOver.value) return;
if (!gameStarted.value) {
startGame();
return;
}
const touchX = e.touches[0].clientX;
const rect = gameCanvas.value.getBoundingClientRect();
const relativeX = touchX - rect.left;
movePet(relativeX < rect.width / 2 ? -1 : 1);
}
function handleMouse(e) {
if (gameOver.value) return;
if (!gameStarted.value) {
startGame();
return;
}
const rect = gameCanvas.value.getBoundingClientRect();
const relativeX = e.clientX - rect.left;
movePet(relativeX < rect.width / 2 ? -1 : 1);
}
function movePet(direction) {
petX += direction * PET_SPEED;
//
petX = Math.max(0, Math.min(petX, canvasWidth - PET_WIDTH));
emit('updatePetX', petX);
}
function spawnBall() {
const size = 12;
balls.push({
x: Math.random() * (canvasWidth - size),
y: -20,
speed: 2 + Math.random() * 2, //
size: size,
color: Math.random() > 0.8 ? '#ffd700' : '#ff4444' //
});
}
function loop(timestamp) {
if (!ctx) return;
const deltaTime = timestamp - lastTime;
lastTime = timestamp;
//
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
if (gameStarted.value && !gameOver.value) {
update(deltaTime);
}
draw();
animationId = requestAnimationFrame(loop);
}
function update(deltaTime) {
//
spawnTimer += deltaTime;
if (spawnTimer > 1000) { //
spawnBall();
spawnTimer = 0;
}
//
for (let i = balls.length - 1; i >= 0; i--) {
const ball = balls[i];
ball.y += ball.speed;
// ()
//
if (ball.y + ball.size > PET_Y &&
ball.y < PET_Y + 32 &&
ball.x + ball.size > petX &&
ball.x < petX + PET_WIDTH) {
//
score.value++;
balls.splice(i, 1);
//
if (score.value >= targetScore) {
gameOver.value = true;
win.value = true;
}
continue;
}
//
if (ball.y > canvasHeight) {
balls.splice(i, 1);
//
}
}
}
function draw() {
//
for (const ball of balls) {
drawPixelBall(ball);
}
}
function drawPixelBall(ball) {
const { x, y, size, color } = ball;
const pSize = 4; //
ctx.fillStyle = color;
// 3x3
// X
// XXX
// X
ctx.fillRect(x + pSize, y, pSize, pSize);
ctx.fillRect(x, y + pSize, pSize * 3, pSize);
ctx.fillRect(x + pSize, y + pSize * 2, pSize, pSize);
}
function handleClose() {
emit('complete', win.value);
emit('close');
}
onMounted(() => {
initGame();
});
onUnmounted(() => {
if (animationId) cancelAnimationFrame(animationId);
});
</script>
<style scoped>
.ball-game-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 50;
pointer-events: auto;
}
.game-canvas {
width: 100%;
height: 100%;
display: block;
}
.game-ui {
position: absolute;
top: 10px;
left: 0;
width: 100%;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
}
.score {
font-family: 'DotGothic16', sans-serif;
font-size: 16px;
color: #8b4513;
background: rgba(255, 255, 255, 0.7);
padding: 2px 8px;
border-radius: 4px;
}
.start-hint {
margin-top: 40px;
font-family: 'DotGothic16', sans-serif;
color: #8b4513;
text-align: center;
animation: pulse 1s infinite;
background: rgba(255, 255, 255, 0.5);
padding: 5px;
border-radius: 4px;
}
.game-over-msg {
margin-top: 40px;
background: rgba(255, 255, 255, 0.9);
padding: 10px;
border: 2px solid #8b4513;
border-radius: 8px;
text-align: center;
pointer-events: auto;
}
.game-over-msg button {
margin-top: 5px;
padding: 4px 10px;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.touch-zones {
position: absolute;
bottom: 10px;
width: 100%;
display: flex;
justify-content: space-between;
padding: 0 20px;
box-sizing: border-box;
pointer-events: none;
opacity: 0.5;
}
.zone {
font-family: 'DotGothic16', sans-serif;
color: #8b4513;
font-size: 12px;
}
@keyframes pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
</style>

View File

@ -190,7 +190,7 @@
v-if="showPetInfo" v-if="showPetInfo"
:petName="CURRENT_PRESET.name" :petName="CURRENT_PRESET.name"
:stage="stage" :stage="stage"
:poopCount="poopCount" :poopCount="stats?.poopCount || 0"
:baseStats="baseStats" :baseStats="baseStats"
:hunger="stats?.hunger || 100" :hunger="stats?.hunger || 100"
:happiness="stats?.happiness || 100" :happiness="stats?.happiness || 100"
@ -212,6 +212,20 @@
@close="currentGame = ''" @close="currentGame = ''"
@complete="handleGameComplete" @complete="handleGameComplete"
/> />
<TrainingGame
v-if="currentGame === 'training'"
@close="currentGame = ''"
@complete="handleGameComplete"
@attack="handleTrainingAttack"
/>
<BallGame
v-if="currentGame === 'ball'"
@close="currentGame = ''"
@complete="handleBallGameComplete"
@updatePetX="handleBallGameUpdate"
/>
<!-- Inventory Screen --> <!-- Inventory Screen -->
<InventoryScreen <InventoryScreen
@ -255,6 +269,8 @@ import PetInfoScreen from './PetInfoScreen.vue';
import InventoryScreen from './InventoryScreen.vue'; import InventoryScreen from './InventoryScreen.vue';
import PlayMenu from './PlayMenu.vue'; import PlayMenu from './PlayMenu.vue';
import GuessingGame from './GuessingGame.vue'; import GuessingGame from './GuessingGame.vue';
import TrainingGame from './TrainingGame.vue';
import BallGame from './BallGame.vue';
import guanyinLots from '../assets/guanyin_100_lots.json'; import guanyinLots from '../assets/guanyin_100_lots.json';
const props = defineProps({ const props = defineProps({
@ -365,18 +381,85 @@ function handleJiaobeiClose() {
function handlePlaySelect(gameType) { function handlePlaySelect(gameType) {
showPlayMenu.value = false; showPlayMenu.value = false;
console.log('Selected game:', gameType); console.log('Selected game:', gameType);
console.log('Setting currentGame to:', gameType);
// Show the selected game // Show the selected game
currentGame.value = gameType; currentGame.value = gameType;
} }
// Watch for game changes to position pet
watch(currentGame, (newGame) => {
if (newGame === 'training') {
// Move pet to right side for training
if (containerRef.value) {
const cw = containerRef.value.clientWidth;
const ch = containerRef.value.clientHeight;
// Position on right side (approx 260px for 300px width)
petX.value = cw - 60;
petY.value = ch - height - 20; // Bottom aligned with padding
// 使調
// false true
isFacingRight.value = true;
}
}
});
function handleTrainingAttack() {
// Trigger attack animation (open mouth)
isMouthOpen.value = true;
setTimeout(() => {
isMouthOpen.value = false;
}, 300);
}
console.log('currentGame is now:', currentGame.value);
function handleGameComplete(won) { function handleGameComplete(won) {
console.log('Game completed, won:', won); console.log('Game completed, won:', won);
currentGame.value = ''; currentGame.value = '';
if (won) { if (won) {
// Reward: increase happiness //
emit('action', 'play'); triggerState('happy', 3000);
//
eventAnimation.value = {
type: 'float-up',
iconClass: 'pixel-note'
};
setTimeout(() => {
eventAnimation.value = null;
}, 2000);
//
stats.value.happiness = Math.min(100, stats.value.happiness + 10);
stats.value.hunger = Math.max(0, stats.value.hunger - 5); //
}
}
function handleBallGameUpdate(x) {
petX.value = x;
// ( x )
// BallGame
}
function handleBallGameComplete(won) {
console.log('Ball game completed, won:', won);
currentGame.value = '';
if (won) {
triggerState('happy', 3000);
//
eventAnimation.value = {
type: 'float-up',
iconClass: 'pixel-note'
};
setTimeout(() => {
eventAnimation.value = null;
}, 2000);
stats.value.happiness = Math.min(100, stats.value.happiness + 15);
stats.value.hunger = Math.max(0, stats.value.hunger - 10);
} }
} }
@ -862,6 +945,9 @@ function moveRandomly() {
return; return;
} }
// Training mode: pet stays in position
if (currentGame.value === 'training') return;
console.log('moveRandomly called. State:', props.state); console.log('moveRandomly called. State:', props.state);
if (props.state === 'sleep' || props.state === 'dead' || props.state === 'eating' || isMedicineActive.value) { if (props.state === 'sleep' || props.state === 'dead' || props.state === 'eating' || isMedicineActive.value) {
updateHeadIconsPosition(); updateHeadIconsPosition();
@ -1711,6 +1797,17 @@ defineExpose({
background: #fff; background: #fff;
} }
.pixel-note {
box-shadow:
1px -2px #000, 2px -2px #000,
1px -1px #000, 2px -1px #000, 3px -1px #000,
1px 0px #000, 3px 0px #000,
1px 1px #000, 3px 1px #000,
-2px 2px #000, -1px 2px #000, 1px 2px #000, 3px 2px #000,
-2px 3px #000, -1px 3px #000, 1px 3px #000, 3px 3px #000;
background: #000;
}
.event-animation.float-up { .event-animation.float-up {
animation: floatUp 2s ease-out forwards; animation: floatUp 2s ease-out forwards;
} }

View File

@ -0,0 +1,330 @@
<template>
<div class="training-layer" @click="handleTap">
<canvas ref="gameCanvas" class="game-canvas"></canvas>
<!-- 移除所有 UI變成純觀賞模式 -->
<div class="training-ui">
<!-- 隱藏 UI -->
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue';
const emit = defineEmits(['close', 'complete', 'attack']);
const gameCanvas = ref(null);
const gameStarted = ref(false);
const gameOver = ref(false);
const score = ref(0);
const attackCount = ref(0);
const totalAttacks = ref(3); // 3
//
let projectiles = [];
// let targets = []; //
let explosions = [];
let animationId = null;
let ctx = null;
let canvasWidth = 300;
let canvasHeight = 150;
// ()
const PET_X = 260;
const PET_Y = 100; //
function handleTap() {
//
}
function initGame() {
if (!gameCanvas.value) return;
const canvas = gameCanvas.value;
//
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
canvasWidth = rect.width;
canvasHeight = rect.height;
ctx = canvas.getContext('2d');
loop(); //
startGame(); //
}
function startGame() {
gameStarted.value = true;
gameOver.value = false;
score.value = 0;
attackCount.value = 0;
projectiles = [];
explosions = [];
//
scheduleAttack(1000);
scheduleAttack(2500);
scheduleAttack(4000);
}
function scheduleAttack(delay) {
setTimeout(() => {
if (!gameOver.value) {
attack();
}
}, delay);
}
// spawnTargetLoop spawnTarget
function attack() {
emit('attack'); //
attackCount.value++;
// (滿)
const isDouble = Math.random() > 0.4; // 60%
// ( X=240) ( X=180)
const spawnX = 180;
const centerY = 80; //
if (isDouble) {
// 滿
//
projectiles.push({
x: spawnX,
y: centerY - 50,
speed: -4, // ( -10)
width: 48,
height: 48,
color: '#000000'
});
//
projectiles.push({
x: spawnX,
y: centerY + 10,
speed: -4, //
width: 48,
height: 48,
color: '#000000'
});
} else {
//
projectiles.push({
x: spawnX,
y: centerY - 32, //
speed: -5, // ( -12)
width: 64,
height: 64,
color: '#000000'
});
}
//
if (attackCount.value >= totalAttacks.value) {
setTimeout(() => {
gameOver.value = true;
handleClose(); //
}, 2000); //
}
}
function loop() {
if (!ctx) return;
// ()
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
if (gameStarted.value) {
update();
draw();
}
animationId = requestAnimationFrame(loop);
}
function update() {
if (gameOver.value) return;
//
for (let i = projectiles.length - 1; i >= 0; i--) {
const p = projectiles[i];
p.x += p.speed;
//
if (p.x < -50) {
projectiles.splice(i, 1);
continue;
}
//
}
//
// ()
updateExplosions();
}
function draw() {
//
//
for (const p of projectiles) {
drawFireball(p);
}
//
ctx.fillStyle = '#ff0000';
for (const e of explosions) {
ctx.fillRect(e.x, e.y, 4, 4);
}
}
// ( V-Pet )
function drawPixelBlock(x, y, w, h, color) {
const pixelSize = 4;
ctx.fillStyle = color || '#000000';
ctx.fillRect(x, y, w, h);
//
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.strokeRect(x, y, w, h);
}
// ()
function drawFireball(p) {
const { x, y, width, height } = p;
// 4x4
const pixelSize = width / 4;
// V-Pet (4x4)
const pattern = [
[0,1,1,0],
[1,1,1,1],
[1,1,1,1],
[0,1,1,0]
];
ctx.fillStyle = p.color || '#000000';
pattern.forEach((row, r) => {
row.forEach((col, c) => {
if (col) {
ctx.fillRect(x + c*pixelSize, y + r*pixelSize, pixelSize, pixelSize);
}
});
});
}
function createExplosion(x, y) {
for(let i=0; i<8; i++) {
explosions.push({
x: x + 10,
y: y + 10,
vx: (Math.random() - 0.5) * 4,
vy: (Math.random() - 0.5) * 4,
life: 10
});
}
}
function updateExplosions() {
for(let i = explosions.length - 1; i >= 0; i--) {
const e = explosions[i];
e.x += e.vx;
e.y += e.vy;
e.life--;
if(e.life <= 0) explosions.splice(i, 1);
}
}
// drawPixelPet
function handleClose() {
if (gameOver.value) {
emit('complete', true);
} else {
emit('complete', false);
}
emit('close');
}
onMounted(() => {
initGame();
});
onUnmounted(() => {
if (animationId) cancelAnimationFrame(animationId);
});
</script>
<style scoped>
.training-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 50; /* 在寵物之上,但在 UI 之下 */
pointer-events: auto; /* 允許點擊 */
}
.game-canvas {
width: 100%;
height: 100%;
display: block;
}
.training-ui {
position: absolute;
top: 10px;
left: 0;
width: 100%;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
}
.score {
font-family: 'DotGothic16', sans-serif;
font-size: 16px;
color: #8b4513;
background: rgba(255, 255, 255, 0.7);
padding: 2px 8px;
border-radius: 4px;
}
.start-hint {
margin-top: 40px;
font-family: 'DotGothic16', sans-serif;
color: #8b4513;
animation: pulse 1s infinite;
}
.game-over-msg {
margin-top: 40px;
background: rgba(255, 255, 255, 0.9);
padding: 10px;
border: 2px solid #8b4513;
border-radius: 8px;
text-align: center;
pointer-events: auto;
}
.game-over-msg button {
margin-top: 5px;
padding: 4px 10px;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
@keyframes pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
</style>