feat: Implement network battle and practice modes with question generation

- Added `questionGenerator.ts` for dynamic question generation based on difficulty levels.
- Created `NetworkBattle.vue` for real-time multiplayer gameplay using PeerJS.
- Developed `Practice.vue` for single-player practice sessions with score tracking and history.
- Updated `yarn.lock` to include new dependencies for PeerJS and message packing.
- Enhanced UI with responsive design and feedback animations for user interactions.
This commit is contained in:
Coldin04 2025-04-25 18:05:34 +08:00
parent 90b3876ea3
commit 6e9c0fef54
9 changed files with 1413 additions and 1 deletions

61
.pnp.cjs generated
View file

@ -37,6 +37,7 @@ const RAW_RUNTIME_STATE =
["eslint-plugin-vue", "virtual:7d6457be6b857bdb44066d1c6f9d2471ca71cf43c0df847ead5ae9289c5e9f98de3a67668fdd588c95fcb212da2ba4f12a34babd1f52380566b5d7bf44a88ca5#npm:10.0.0"],\
["jiti", "npm:2.4.2"],\
["npm-run-all2", "npm:7.0.2"],\
["peerjs", "npm:1.5.4"],\
["pinia", "virtual:7d6457be6b857bdb44066d1c6f9d2471ca71cf43c0df847ead5ae9289c5e9f98de3a67668fdd588c95fcb212da2ba4f12a34babd1f52380566b5d7bf44a88ca5#npm:3.0.2"],\
["prettier", "npm:3.5.3"],\
["typescript", "patch:typescript@npm%3A5.8.3#optional!builtin<compat/typescript>::version=5.8.3&hash=5786d5"],\
@ -1001,6 +1002,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["@msgpack/msgpack", [\
["npm:2.8.0", {\
"packageLocation": "../../../../AppData/Local/Yarn/Berry/cache/@msgpack-msgpack-npm-2.8.0-c4768c71d4-10c0.zip/node_modules/@msgpack/msgpack/",\
"packageDependencies": [\
["@msgpack/msgpack", "npm:2.8.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["@nodelib/fs.scandir", [\
["npm:2.1.5", {\
"packageLocation": "../../../../AppData/Local/Yarn/Berry/cache/@nodelib-fs.scandir-npm-2.1.5-89c67370dd-10c0.zip/node_modules/@nodelib/fs.scandir/",\
@ -2107,6 +2117,7 @@ const RAW_RUNTIME_STATE =
["eslint-plugin-vue", "virtual:7d6457be6b857bdb44066d1c6f9d2471ca71cf43c0df847ead5ae9289c5e9f98de3a67668fdd588c95fcb212da2ba4f12a34babd1f52380566b5d7bf44a88ca5#npm:10.0.0"],\
["jiti", "npm:2.4.2"],\
["npm-run-all2", "npm:7.0.2"],\
["peerjs", "npm:1.5.4"],\
["pinia", "virtual:7d6457be6b857bdb44066d1c6f9d2471ca71cf43c0df847ead5ae9289c5e9f98de3a67668fdd588c95fcb212da2ba4f12a34babd1f52380566b5d7bf44a88ca5#npm:3.0.2"],\
["prettier", "npm:3.5.3"],\
["typescript", "patch:typescript@npm%3A5.8.3#optional!builtin<compat/typescript>::version=5.8.3&hash=5786d5"],\
@ -2765,6 +2776,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["eventemitter3", [\
["npm:4.0.7", {\
"packageLocation": "../../../../AppData/Local/Yarn/Berry/cache/eventemitter3-npm-4.0.7-7afcdd74ae-10c0.zip/node_modules/eventemitter3/",\
"packageDependencies": [\
["eventemitter3", "npm:4.0.7"]\
],\
"linkType": "HARD"\
}]\
]],\
["execa", [\
["npm:9.5.2", {\
"packageLocation": "../../../../AppData/Local/Yarn/Berry/cache/execa-npm-9.5.2-f74ad877bc-10c0.zip/node_modules/execa/",\
@ -3951,6 +3971,28 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["peerjs", [\
["npm:1.5.4", {\
"packageLocation": "../../../../AppData/Local/Yarn/Berry/cache/peerjs-npm-1.5.4-b1f39eb912-10c0.zip/node_modules/peerjs/",\
"packageDependencies": [\
["peerjs", "npm:1.5.4"],\
["@msgpack/msgpack", "npm:2.8.0"],\
["eventemitter3", "npm:4.0.7"],\
["peerjs-js-binarypack", "npm:2.1.0"],\
["webrtc-adapter", "npm:9.0.3"]\
],\
"linkType": "HARD"\
}]\
]],\
["peerjs-js-binarypack", [\
["npm:2.1.0", {\
"packageLocation": "../../../../AppData/Local/Yarn/Berry/cache/peerjs-js-binarypack-npm-2.1.0-59ed32add7-10c0.zip/node_modules/peerjs-js-binarypack/",\
"packageDependencies": [\
["peerjs-js-binarypack", "npm:2.1.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["perfect-debounce", [\
["npm:1.0.0", {\
"packageLocation": "../../../../AppData/Local/Yarn/Berry/cache/perfect-debounce-npm-1.0.0-0968810009-10c0.zip/node_modules/perfect-debounce/",\
@ -4226,6 +4268,15 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["sdp", [\
["npm:3.2.0", {\
"packageLocation": "../../../../AppData/Local/Yarn/Berry/cache/sdp-npm-3.2.0-b1a5f85233-10c0.zip/node_modules/sdp/",\
"packageDependencies": [\
["sdp", "npm:3.2.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["semver", [\
["npm:6.3.1", {\
"packageLocation": "../../../../AppData/Local/Yarn/Berry/cache/semver-npm-6.3.1-bcba31fdbe-10c0.zip/node_modules/semver/",\
@ -4959,6 +5010,16 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["webrtc-adapter", [\
["npm:9.0.3", {\
"packageLocation": "../../../../AppData/Local/Yarn/Berry/cache/webrtc-adapter-npm-9.0.3-9d13b6cc5b-10c0.zip/node_modules/webrtc-adapter/",\
"packageDependencies": [\
["webrtc-adapter", "npm:9.0.3"],\
["sdp", "npm:3.2.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["which", [\
["npm:2.0.2", {\
"packageLocation": "../../../../AppData/Local/Yarn/Berry/cache/which-npm-2.0.2-320ddf72f7-10c0.zip/node_modules/which/",\

Binary file not shown.

View file

@ -13,6 +13,7 @@
"format": "prettier --write src/"
},
"dependencies": {
"peerjs": "^1.5.4",
"pinia": "^3.0.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"

View file

@ -17,6 +17,16 @@ const router = createRouter({
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue'),
},
{
path: '/practice',
name: 'Practice',
component: () => import('../views/Practice.vue'),
},
{
path: '/network-battle',
name: 'NetworkBattle',
component: () => import('../views/NetworkBattle.vue'),
},
],
})

View file

@ -0,0 +1,81 @@
export function generateQuestion(difficulty: number): { question: string, answer: number } {
const ops = ['+', '-', '×', '÷'];
const generateEasyAddition = (): { question: string, answer: number } => {
const a = Math.floor(Math.random() * 10);
const b = Math.floor(Math.random() * 10);
return {
question: `${a} + ${b}`,
answer: a + b,
};
};
const generateEasySubtraction = (): { question: string, answer: number } => {
const a = Math.floor(Math.random() * 10);
const b = Math.floor(Math.random() * a); // 保证 a > b
return {
question: `${a} - ${b}`,
answer: a - b,
};
};
const generateMultiplication = (): { question: string, answer: number } => {
const a = Math.floor(Math.random() * 10);
const b = Math.floor(Math.random() * 10);
return {
question: `${a} × ${b}`,
answer: a * b,
};
};
const generateMediumAddition = (): { question: string, answer: number } => {
const a = Math.floor(Math.random() * 100);
const b = Math.floor(Math.random() * 100);
return {
question: `${a} + ${b}`,
answer: a + b,
};
};
const generateMediumSubtraction = (): { question: string, answer: number } => {
const a = Math.floor(Math.random() * 100);
const b = Math.floor(Math.random() * a); // 保证 a > b
return {
question: `${a} - ${b}`,
answer: a - b,
};
};
const generateMultiplicationDivision = (): { question: string, answer: number } => {
const table = [1, 2, 3, 4, 5, 6, 7, 8, 9]; // 乘法表
const a = table[Math.floor(Math.random() * table.length)];
const b = table[Math.floor(Math.random() * table.length)];
const result = a * b;
return {
question: `${result} ÷ ${a}`,
answer: b,
};
};
switch (difficulty) {
case 1: // 10以内加减法
return Math.random() < 0.5
? generateEasyAddition()
: generateEasySubtraction();
case 2: // 10以内乘法
return generateMultiplication();
case 3: // 100以内加减法
return Math.random() < 0.5
? generateMediumAddition()
: generateMediumSubtraction();
case 4: // 乘法表除法
return generateMultiplicationDivision();
default:
throw new Error('Invalid difficulty level');
}
}

View file

@ -6,7 +6,7 @@
<p>准备好练习你的数学计算能力或者与他人一决高下了吗</p>
<div class="actions">
<router-link to="/practice" class="btn btn-dopamine">单人练习</router-link>
<router-link to="/pk" class="btn btn-dopamine">在线比赛</router-link>
<router-link to="/network-battle" class="btn btn-dopamine">在线比赛</router-link>
</div>
</div>
</transition>

838
src/views/NetworkBattle.vue Normal file
View file

@ -0,0 +1,838 @@
<template>
<div class="network-battle-page">
<header>
<h1>局域网对战</h1>
<div v-if="!isConnected && !isGameActive" class="connection-section">
<div class="connection-type">
<button @click="mode = 'host'" :class="{ active: mode === 'host' }">创建房间</button>
<button @click="mode = 'join'" :class="{ active: mode === 'join' }">加入房间</button>
</div>
<div v-if="mode === 'host'" class="host-section">
<div v-if="localPeerId" class="local-info">
<p>你的房间ID: <span class="peer-id">{{ localPeerId }}</span></p>
<button @click="copyToClipboard(localPeerId)" class="copy-button">复制房间ID</button>
</div>
<p v-if="connectionStatus">{{ connectionStatus }}</p>
</div>
<div v-if="mode === 'join'" class="join-section">
<div class="input-group">
<label for="remotePeerId">输入房间ID:</label>
<input id="remotePeerId" v-model="remotePeerId" placeholder="输入对方的房间ID" />
</div>
<button @click="connectToPeer" :disabled="!remotePeerId">连接</button>
<p v-if="connectionStatus">{{ connectionStatus }}</p>
</div>
</div>
<div v-if="isConnected && !isGameActive" class="game-settings">
<label for="difficulty">选择难度:</label>
<select id="difficulty" v-model="selectedDifficulty">
<option value="1">10以内加减法</option>
<option value="2">10以内乘法</option>
<option value="3">100以内加减法</option>
<option value="4">乘法表除法</option>
</select>
<button @click="startGame">开始游戏</button>
</div>
</header>
<main v-if="isGameActive">
<div class="players-info">
<div class="player local" :class="{ 'player-turn': localPlayerTurn }">
<h3>我方</h3>
<p class="score">得分: {{ localScore }}</p>
</div>
<div class="player remote" :class="{ 'player-turn': !localPlayerTurn }">
<h3>对方</h3>
<p class="score">得分: {{ remoteScore }}</p>
</div>
</div>
<section class="question-section">
<p>{{ currentQuestion.question }}</p>
<input type="text" v-model="userAnswer" @keyup.enter="submitAnswer"
:class="{'correct-answer': answerFeedback === 'correct',
'wrong-answer': answerFeedback === 'wrong'}"
:disabled="!isGameActive || !localPlayerTurn" />
<button @click="submitAnswer" :disabled="!isGameActive || !localPlayerTurn">提交答案</button>
</section>
<div class="feedback-container">
<transition name="fade">
<div v-if="answerFeedback === 'correct'" class="feedback correct">
<span> 回答正确!</span>
</div>
</transition>
<transition name="fade">
<div v-if="answerFeedback === 'wrong'" class="feedback wrong">
<span> 正确答案: {{ lastCorrectAnswer }}</span>
</div>
</transition>
</div>
<section class="timer-section">
<p>剩余时间: {{ timeLeft }} </p>
</section>
<div class="game-messages">
<div v-if="gameMessage" class="game-message" :class="gameMessageType">
{{ gameMessage }}
</div>
</div>
</main>
<div v-if="gameEnded" class="game-result-overlay">
<div class="game-result">
<h2>游戏结束</h2>
<div class="final-scores">
<p>我方得分: {{ localScore }}</p>
<p>对方得分: {{ remoteScore }}</p>
</div>
<h3 class="result-message" :class="{ 'win': localScore > remoteScore, 'lose': localScore < remoteScore, 'draw': localScore === remoteScore }">
{{ resultMessage }}
</h3>
<button @click="resetGame">返回</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue';
import { generateQuestion } from '@/utils/questionGenerator';
import Peer, { DataConnection } from 'peerjs';
// WebRTC
const mode = ref<'host' | 'join'>('host');
const peer = ref<Peer | null>(null);
const connection = ref<DataConnection | null>(null);
const localPeerId = ref<string>('');
const remotePeerId = ref<string>('');
const isConnected = ref<boolean>(false);
const connectionStatus = ref<string>('');
//
const isGameActive = ref<boolean>(false);
const gameEnded = ref<boolean>(false);
const selectedDifficulty = ref<string>('1');
const currentQuestion = ref<{ question: string, answer: number }>({ question: '', answer: 0 });
const userAnswer = ref<string>('');
const timeLeft = ref<number>(30);
const localScore = ref<number>(0);
const remoteScore = ref<number>(0);
const localPlayerTurn = ref<boolean>(true);
const answerFeedback = ref<'none' | 'correct' | 'wrong'>('none');
const lastCorrectAnswer = ref<string>('');
const timerInterval = ref<number | null>(null);
const gameMessage = ref<string>('');
const gameMessageType = ref<string>('');
//
const resultMessage = computed(() => {
if (localScore.value > remoteScore.value) {
return '你赢了!';
} else if (localScore.value < remoteScore.value) {
return '你输了!';
} else {
return '平局!';
}
});
// PeerJS
onMounted(() => {
initPeer();
});
//
onUnmounted(() => {
if (connection.value) {
connection.value.close();
}
if (peer.value) {
peer.value.destroy();
}
clearTimerInterval();
});
//
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text)
.then(() => {
showGameMessage('已复制到剪贴板', 'success');
})
.catch(err => {
console.error('复制失败:', err);
showGameMessage('复制失败,请手动复制', 'error');
});
}
// PeerJS
function initPeer() {
peer.value = new Peer({
debug: 2,
config: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:global.stun.twilio.com:3478' }
]
}
});
peer.value.on('open', (id) => {
localPeerId.value = id;
connectionStatus.value = '准备就绪,等待连接...';
});
peer.value.on('connection', (conn) => {
handleConnection(conn);
});
peer.value.on('error', (err) => {
console.error('PeerJS错误:', err);
connectionStatus.value = `连接错误: ${err}`;
showGameMessage('连接错误,请重试', 'error');
});
}
//
function connectToPeer() {
if (!peer.value || !remotePeerId.value) return;
connectionStatus.value = '正在连接...';
try {
const conn = peer.value.connect(remotePeerId.value, {
reliable: true
});
handleConnection(conn);
} catch (error) {
console.error('连接失败:', error);
connectionStatus.value = '连接失败,请重试';
}
}
//
function handleConnection(conn: DataConnection) {
connection.value = conn;
conn.on('open', () => {
isConnected.value = true;
connectionStatus.value = '连接成功!';
showGameMessage('连接成功!', 'success');
//
if (mode.value === 'join') {
localPlayerTurn.value = false;
}
});
conn.on('data', (data: any) => {
handleReceivedData(data);
});
conn.on('close', () => {
isConnected.value = false;
connectionStatus.value = '连接已关闭';
if (isGameActive.value) {
showGameMessage('对方已断开连接', 'error');
endGame();
}
});
conn.on('error', (err) => {
console.error('连接错误:', err);
connectionStatus.value = `连接错误: ${err}`;
showGameMessage('连接错误', 'error');
});
}
//
function startGame() {
if (!isConnected.value || !connection.value) return;
//
connection.value.send({
type: 'game-settings',
difficulty: selectedDifficulty.value
});
//
timeLeft.value = 30;
localScore.value = 0;
remoteScore.value = 0;
isGameActive.value = true;
gameEnded.value = false;
localPlayerTurn.value = mode.value === 'host'; //
//
generateNewQuestion();
//
startTimer();
//
connection.value.send({
type: 'game-started'
});
}
//
function generateNewQuestion() {
try {
currentQuestion.value = generateQuestion(parseInt(selectedDifficulty.value));
//
if (localPlayerTurn.value && connection.value) {
connection.value.send({
type: 'new-question',
question: currentQuestion.value.question,
answer: currentQuestion.value.answer
});
}
} catch (error) {
console.error(error);
showGameMessage('生成题目出错', 'error');
}
}
//
function submitAnswer() {
if (!isGameActive.value || !localPlayerTurn.value) return;
const isCorrect = currentQuestion.value.answer.toString() === userAnswer.value;
lastCorrectAnswer.value = currentQuestion.value.answer.toString();
if (isCorrect) {
localScore.value += 1;
answerFeedback.value = 'correct';
} else {
answerFeedback.value = 'wrong';
}
//
if (connection.value) {
connection.value.send({
type: 'answer-result',
isCorrect,
score: localScore.value
});
}
//
localPlayerTurn.value = false;
//
generateNewQuestion();
userAnswer.value = '';
//
setTimeout(() => {
answerFeedback.value = 'none';
}, 1500);
}
//
function startTimer() {
clearTimerInterval();
timerInterval.value = window.setInterval(() => {
timeLeft.value--;
if (timeLeft.value <= 0) {
clearTimerInterval();
endGame();
//
if (connection.value) {
connection.value.send({
type: 'game-ended'
});
}
}
}, 1000);
}
//
function clearTimerInterval() {
if (timerInterval.value !== null) {
clearInterval(timerInterval.value);
timerInterval.value = null;
}
}
//
function endGame() {
isGameActive.value = false;
gameEnded.value = true;
clearTimerInterval();
}
//
function resetGame() {
localScore.value = 0;
remoteScore.value = 0;
timeLeft.value = 30;
isGameActive.value = false;
gameEnded.value = false;
userAnswer.value = '';
answerFeedback.value = 'none';
}
//
function handleReceivedData(data: any) {
console.log('收到数据:', data);
switch (data.type) {
case 'game-settings':
//
selectedDifficulty.value = data.difficulty;
break;
case 'game-started':
//
isGameActive.value = true;
gameEnded.value = false;
timeLeft.value = 30;
startTimer();
break;
case 'new-question':
//
currentQuestion.value = {
question: data.question,
answer: data.answer
};
//
localPlayerTurn.value = true;
break;
case 'answer-result':
//
remoteScore.value = data.score;
if (data.isCorrect) {
showGameMessage('对方答对了!', 'info');
} else {
showGameMessage('对方答错了!', 'info');
}
//
localPlayerTurn.value = true;
generateNewQuestion();
break;
case 'game-ended':
//
endGame();
break;
}
}
//
function showGameMessage(message: string, type: 'success' | 'error' | 'info' = 'info') {
gameMessage.value = message;
gameMessageType.value = type;
setTimeout(() => {
gameMessage.value = '';
}, 3000);
}
</script>
<style scoped>
.network-battle-page {
background: linear-gradient(135deg, #f7f9fc, #e2e7ed);
max-width: 800px;
margin: 2rem auto;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
text-align: center;
padding: 2rem;
position: relative;
}
.network-battle-page h1 {
font-size: 2.2rem;
color: #333;
margin-bottom: 1.5rem;
}
.connection-section {
background-color: rgba(255, 255, 255, 0.7);
border-radius: 10px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
}
.connection-type {
display: flex;
justify-content: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.connection-type button {
background: rgba(255, 255, 255, 0.8);
color: #555;
border: 2px solid #ddd;
border-radius: 30px;
padding: 0.7rem 1.5rem;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
}
.connection-type button.active {
background: linear-gradient(90deg, #84b9ff, #32a8ff, #0faeff);
color: white;
border-color: transparent;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.host-section, .join-section {
margin-top: 1rem;
}
.local-info {
background-color: rgba(255, 255, 255, 0.9);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
border: 1px solid #ddd;
}
.peer-id {
font-weight: bold;
color: #0077cc;
background: rgba(0, 119, 204, 0.1);
padding: 0.3rem 0.6rem;
border-radius: 4px;
margin: 0 0.3rem;
}
.copy-button {
background: rgba(0, 119, 204, 0.1);
color: #0077cc;
border: 1px solid #0077cc;
border-radius: 20px;
padding: 0.3rem 0.8rem;
font-size: 0.9rem;
margin-left: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.copy-button:hover {
background: rgba(0, 119, 204, 0.2);
}
.input-group {
margin-bottom: 1rem;
}
.input-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
color: #555;
}
.input-group input {
padding: 0.8rem;
font-size: 1rem;
border-radius: 20px;
border: 2px solid #ddd;
width: 80%;
max-width: 400px;
text-align: center;
outline: none;
transition: all 0.3s ease;
}
.input-group input:focus {
border-color: #007ACC;
box-shadow: 0 0 0 2px rgba(0,122,204,0.2);
}
.game-settings {
margin: 1.5rem 0;
}
label {
margin-left: 1rem;
font-weight: bold;
color: #555;
}
select {
padding: 0.6rem 1rem;
font-size: 1rem;
border-radius: 20px;
border: 2px solid #ddd;
background-color: #f8f9fa;
color: #555;
cursor: pointer;
outline: none;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
select:focus {
border-color: #007ACC;
box-shadow: 0 0 0 2px rgba(0,122,204,0.2);
}
button {
background: linear-gradient(90deg, #84b9ff, #32a8ff, #0faeff);
color: white;
border: none;
border-radius: 30px;
padding: 0.7rem 1.5rem;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
margin: 0.5rem;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 8px rgba(0,0,0,0.15);
}
button:disabled {
background: #cccccc;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.players-info {
display: flex;
justify-content: space-around;
margin-bottom: 1.5rem;
}
.player {
background-color: rgba(255, 255, 255, 0.7);
border-radius: 10px;
padding: 1rem;
width: 45%;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.player-turn {
border-color: #32a8ff;
background-color: rgba(50, 168, 255, 0.1);
box-shadow: 0 0 8px rgba(50, 168, 255, 0.3);
}
.player h3 {
margin: 0 0 0.5rem 0;
font-size: 1.2rem;
color: #333;
}
.score {
font-size: 1.4rem;
font-weight: bold;
margin: 0.5rem 0;
color: #42b883;
}
.question-section p {
font-size: 1.8rem;
font-weight: bold;
margin: 1.5rem 0;
color: #333;
}
.question-section input {
margin: 1rem 0;
padding: 0.8rem;
font-size: 1.1rem;
border-radius: 20px;
border: 2px solid #ddd;
width: 200px;
text-align: center;
outline: none;
transition: all 0.3s ease;
}
.question-section input:focus {
border-color: #007ACC;
box-shadow: 0 0 0 2px rgba(0,122,204,0.2);
}
.question-section input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.timer-section p {
font-size: 1.4rem;
font-weight: bold;
color: #e74c3c;
}
.feedback-container {
height: 30px;
margin: 10px 0;
position: relative;
}
.feedback {
padding: 6px 12px;
border-radius: 20px;
display: inline-block;
font-weight: bold;
position: absolute;
left: 50%;
transform: translateX(-50%);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.feedback.correct {
background-color: rgba(66, 184, 131, 0.2);
color: #2a8c5f;
border: 1px solid #42b883;
}
.feedback.wrong {
background-color: rgba(231, 76, 60, 0.2);
color: #c0392b;
border: 1px solid #e74c3c;
}
.correct-answer {
animation: pulse-green 0.5s;
border-color: #42b883 !important;
}
.wrong-answer {
animation: pulse-red 0.5s;
border-color: #e74c3c !important;
}
.game-messages {
height: 40px;
margin: 1rem 0;
display: flex;
justify-content: center;
align-items: center;
}
.game-message {
padding: 0.5rem 1rem;
border-radius: 20px;
font-weight: bold;
animation: fade-in 0.5s;
}
.game-message.success {
background-color: rgba(66, 184, 131, 0.2);
color: #2a8c5f;
}
.game-message.error {
background-color: rgba(231, 76, 60, 0.2);
color: #c0392b;
}
.game-message.info {
background-color: rgba(52, 152, 219, 0.2);
color: #2980b9;
}
.game-result-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
animation: fade-in 0.5s;
}
.game-result {
background-color: white;
border-radius: 12px;
padding: 2rem;
width: 90%;
max-width: 500px;
text-align: center;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.game-result h2 {
font-size: 2rem;
margin-bottom: 1.5rem;
color: #333;
}
.final-scores {
display: flex;
justify-content: space-around;
margin-bottom: 1.5rem;
}
.final-scores p {
font-size: 1.2rem;
font-weight: bold;
}
.result-message {
font-size: 2rem;
margin: 1.5rem 0;
}
.result-message.win {
color: #42b883;
}
.result-message.lose {
color: #e74c3c;
}
.result-message.draw {
color: #f39c12;
}
/* 动画效果 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s, transform 0.5s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-10px) translateX(-50%);
}
@keyframes pulse-green {
0% { box-shadow: 0 0 0 0 rgba(66, 184, 131, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(66, 184, 131, 0); }
100% { box-shadow: 0 0 0 0 rgba(66, 184, 131, 0); }
}
@keyframes pulse-red {
0% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(231, 76, 60, 0); }
100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

371
src/views/Practice.vue Normal file
View file

@ -0,0 +1,371 @@
<template>
<div class="practice-page">
<header>
<h1>单人练习</h1>
<label for="difficulty">选择难度:</label>
<select id="difficulty" v-model="selectedDifficulty">
<option value="1">10以内加减法</option>
<option value="2">10以内乘法</option>
<option value="3">100以内加减法</option>
<option value="4">乘法表除法</option>
</select>
<button @click="startGame">开始游戏</button>
</header>
<main v-if="isGameActive">
<section class="question-section">
<p>{{ currentQuestion.question }}</p>
<input type="text" v-model="userAnswer" @keyup.enter="submitAnswer"
:class="{'correct-answer': answerFeedback === 'correct',
'wrong-answer': answerFeedback === 'wrong'}" />
<button @click="submitAnswer">提交答案</button>
</section>
<div class="feedback-container">
<transition name="fade">
<div v-if="answerFeedback === 'correct'" class="feedback correct">
<span> 回答正确!</span>
</div>
</transition>
<transition name="fade">
<div v-if="answerFeedback === 'wrong'" class="feedback wrong">
<span> 正确答案: {{ lastCorrectAnswer }}</span>
</div>
</transition>
</div>
<section class="timer-section">
<p>剩余时间: {{ timeLeft }} </p>
</section>
<section class="score-section">
<p>得分: {{ score }}</p>
</section>
</main>
<footer v-if="!isGameActive">
<p>历史成绩:</p>
<ul>
<li v-for="(record, index) in historyRecords" :key="index">{{ record }}</li>
</ul>
<button @click="resetGame">重置历史记录</button>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { generateQuestion } from '@/utils/questionGenerator';
//
const selectedDifficulty = ref('1'); //
const isGameActive = ref(false);
const currentQuestion = ref<{ question: string, answer: number }>({ question: '', answer: 0 });
const userAnswer = ref('');
const timeLeft = ref(30); // 30
const score = ref(0);
const historyRecords = ref<string[]>([]);
const questionsGenerated = ref<number>(0); //
const answerFeedback = ref('none'); // : 'none', 'correct', 'wrong'
const lastCorrectAnswer = ref(''); //
//
function startGame() {
isGameActive.value = true;
timeLeft.value = 30; //
score.value = 0;
generateNewQuestion();
startTimer();
}
//
function generateNewQuestion() {
try {
currentQuestion.value = generateQuestion(parseInt(selectedDifficulty.value));
} catch (error) {
console.error(error);
}
}
//
function submitAnswer() {
const isCorrect = currentQuestion.value.answer.toString() === userAnswer.value;
lastCorrectAnswer.value = currentQuestion.value.answer.toString();
if (isCorrect) {
score.value += 1;
answerFeedback.value = 'correct';
} else {
answerFeedback.value = 'wrong';
}
questionsGenerated.value++;
generateNewQuestion();
userAnswer.value = '';
//
setTimeout(() => {
answerFeedback.value = 'none';
}, 1500);
}
//
function startTimer() {
const intervalId = setInterval(() => {
timeLeft.value--;
if (timeLeft.value <= 0) {
clearInterval(intervalId);
endGame();
}
}, 1000);
}
//
function endGame() {
isGameActive.value = false;
historyRecords.value.push(`得分: ${score.value}, 生成题目数: ${questionsGenerated.value}`);
saveHistoryToLocalStorage();
}
// localStorage
function saveHistoryToLocalStorage() {
const records = JSON.parse(localStorage.getItem('history') || '[]');
records.push(`得分: ${score.value}, 生成题目数: ${questionsGenerated.value}`);
localStorage.setItem('history', JSON.stringify(records));
}
//
function resetGame() {
historyRecords.value = [];
localStorage.removeItem('history');
questionsGenerated.value = 0;
}
//
onMounted(() => {
historyRecords.value = JSON.parse(localStorage.getItem('history') || '[]');
});
//
onUnmounted(() => {
timeLeft.value = 0;
});
</script>
<style scoped>
.practice-page {
background: linear-gradient(135deg, #f7f9fc, #e2e7ed);
max-width: 800px;
margin: 2rem auto;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
text-align: center;
padding: 2rem;
}
.practice-page h1 {
font-size: 2.2rem;
color: #333;
margin-bottom: 1.5rem;
}
header label {
margin-left: 1rem;
font-weight: bold;
color: #555;
}
header select {
padding: 0.6rem 1rem;
font-size: 1rem;
border-radius: 20px;
border: 2px solid #ddd;
background-color: #f8f9fa;
color: #555;
cursor: pointer;
outline: none;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
header select:focus {
border-color: #007ACC;
box-shadow: 0 0 0 2px rgba(0,122,204,0.2);
}
button {
background: linear-gradient(90deg, #84b9ff, #32a8ff, #0faeff);
color: white;
border: none;
border-radius: 30px;
padding: 0.7rem 1.5rem;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
margin: 0.5rem;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 8px rgba(0,0,0,0.15);
background: linear-gradient(90deg, #84b9ff, #32a8ff, #0faeff);
}
header button {
margin-top: 1.5rem;
padding: 0.7rem 1.8rem;
}
.question-section p {
font-size: 1.8rem;
font-weight: bold;
margin: 1.5rem 0;
color: #333;
}
.question-section input {
margin: 1rem 0;
padding: 0.8rem;
font-size: 1.1rem;
border-radius: 20px;
border: 2px solid #ddd;
width: 200px;
text-align: center;
outline: none;
transition: all 0.3s ease;
}
.question-section input:focus {
border-color: #007ACC;
box-shadow: 0 0 0 2px rgba(0,122,204,0.2);
}
.timer-section p {
font-size: 1.4rem;
font-weight: bold;
color: #e74c3c;
}
.score-section p {
font-size: 1.4rem;
font-weight: bold;
color: #42b883;
}
footer {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #ddd;
}
footer p {
font-size: 1.2rem;
font-weight: bold;
color: #555;
margin-bottom: 1rem;
}
footer ul {
list-style-type: none;
padding: 0;
margin: 1rem 0;
max-height: 300px;
overflow-y: auto;
/* 移除背景色和内边距,使其更扁平 */
background-color: transparent;
border-radius: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
footer li {
margin: 0;
padding: 0.8rem 1rem;
background-color: rgba(255, 255, 255, 0.7);
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
transition: all 0.2s ease;
border-left: 3px solid #32a8ff;
}
footer li:hover {
background-color: white;
transform: translateX(2px);
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
footer button {
background: linear-gradient(90deg, #ff7675, #d63031);
margin-top: 1.5rem;
padding: 0.6rem 1.4rem;
}
footer button:hover {
background: linear-gradient(90deg, #ff6b6b, #c0392b);
}
/* 答题反馈动画 */
.feedback-container {
height: 30px;
margin: 10px 0;
position: relative;
}
.feedback {
padding: 6px 12px;
border-radius: 20px;
display: inline-block;
font-weight: bold;
position: absolute;
left: 50%;
transform: translateX(-50%);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.feedback.correct {
background-color: rgba(66, 184, 131, 0.2);
color: #2a8c5f;
border: 1px solid #42b883;
}
.feedback.wrong {
background-color: rgba(231, 76, 60, 0.2);
color: #c0392b;
border: 1px solid #e74c3c;
}
/* 输入框的反馈效果 */
.correct-answer {
animation: pulse-green 0.5s;
border-color: #42b883 !important;
}
.wrong-answer {
animation: pulse-red 0.5s;
border-color: #e74c3c !important;
}
/* 淡入淡出动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s, transform 0.5s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-10px) translateX(-50%);
}
/* 脉动动画效果 */
@keyframes pulse-green {
0% { box-shadow: 0 0 0 0 rgba(66, 184, 131, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(66, 184, 131, 0); }
100% { box-shadow: 0 0 0 0 rgba(66, 184, 131, 0); }
}
@keyframes pulse-red {
0% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(231, 76, 60, 0); }
100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0); }
}
</style>

View file

@ -712,6 +712,13 @@ __metadata:
languageName: node
linkType: hard
"@msgpack/msgpack@npm:^2.8.0":
version: 2.8.0
resolution: "@msgpack/msgpack@npm:2.8.0"
checksum: 10c0/5964ed3daad9ccf314238da81c91152dc693bca167b2469445c1d3ce0495443612e543d052281061a91ec48ed44a6a49dd3a334b5d0dbe2dc2db6ea6143e5787
languageName: node
linkType: hard
"@nodelib/fs.scandir@npm:2.1.5":
version: 2.1.5
resolution: "@nodelib/fs.scandir@npm:2.1.5"
@ -1511,6 +1518,7 @@ __metadata:
eslint-plugin-vue: "npm:~10.0.0"
jiti: "npm:^2.4.2"
npm-run-all2: "npm:^7.0.2"
peerjs: "npm:^1.5.4"
pinia: "npm:^3.0.1"
prettier: "npm:^3.5.3"
typescript: "npm:~5.8.0"
@ -2098,6 +2106,13 @@ __metadata:
languageName: node
linkType: hard
"eventemitter3@npm:^4.0.7":
version: 4.0.7
resolution: "eventemitter3@npm:4.0.7"
checksum: 10c0/5f6d97cbcbac47be798e6355e3a7639a84ee1f7d9b199a07017f1d2f1e2fe236004d14fa5dfaeba661f94ea57805385e326236a6debbc7145c8877fbc0297c6b
languageName: node
linkType: hard
"execa@npm:^9.5.2":
version: 9.5.2
resolution: "execa@npm:9.5.2"
@ -3160,6 +3175,25 @@ __metadata:
languageName: node
linkType: hard
"peerjs-js-binarypack@npm:^2.1.0":
version: 2.1.0
resolution: "peerjs-js-binarypack@npm:2.1.0"
checksum: 10c0/562b6ef27b0de6b152dcab3e13684d38decfff8b51967e12ae14707b1292a78c162ce6befc9ae63ffefec81555eb37372a0e8a530aeabdc69bea3348e4a43790
languageName: node
linkType: hard
"peerjs@npm:^1.5.4":
version: 1.5.4
resolution: "peerjs@npm:1.5.4"
dependencies:
"@msgpack/msgpack": "npm:^2.8.0"
eventemitter3: "npm:^4.0.7"
peerjs-js-binarypack: "npm:^2.1.0"
webrtc-adapter: "npm:^9.0.0"
checksum: 10c0/f52d95693059917ae701c829f766b8c8c6ad13041f16e294d3f82a9f49357e769800895cba445139c3586c89a59f6d818b58c4f1251612139fa6a891d951ace7
languageName: node
linkType: hard
"perfect-debounce@npm:^1.0.0":
version: 1.0.0
resolution: "perfect-debounce@npm:1.0.0"
@ -3434,6 +3468,13 @@ __metadata:
languageName: node
linkType: hard
"sdp@npm:^3.2.0":
version: 3.2.0
resolution: "sdp@npm:3.2.0"
checksum: 10c0/fa0146132b4c9185f276b80e09f52259b103e609565ac40c560250dbe7fc47723d30530c0db9cac6217c83153944a71af81fa70dc0367f195aabcf110f8185fd
languageName: node
linkType: hard
"semver@npm:^6.3.1":
version: 6.3.1
resolution: "semver@npm:6.3.1"
@ -3990,6 +4031,15 @@ __metadata:
languageName: node
linkType: hard
"webrtc-adapter@npm:^9.0.0":
version: 9.0.3
resolution: "webrtc-adapter@npm:9.0.3"
dependencies:
sdp: "npm:^3.2.0"
checksum: 10c0/bb8c1626cc0e18bd34c03074fc6a541666dbe473c8f063c5d0ea2a3a3a4887f498212a131c936777f660382185f44941476609a50df5f1543015240623375725
languageName: node
linkType: hard
"which@npm:^2.0.1":
version: 2.0.2
resolution: "which@npm:2.0.2"