|
|
from flask import Flask, render_template_string, jsonify, request |
|
|
from huggingface_hub import InferenceClient |
|
|
import os |
|
|
import random |
|
|
|
|
|
app = Flask(__name__) |
|
|
|
|
|
|
|
|
HF_TOKEN = os.environ.get("HF_TOKEN", "") |
|
|
|
|
|
|
|
|
HTML_TEMPLATE = """ |
|
|
<!DOCTYPE html> |
|
|
<html lang="vi"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>English Shooting Game</title> |
|
|
<style> |
|
|
* { |
|
|
margin: 0; |
|
|
padding: 0; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
body { |
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
min-height: 100vh; |
|
|
display: flex; |
|
|
justify-content: center; |
|
|
align-items: center; |
|
|
padding: 20px; |
|
|
} |
|
|
|
|
|
.game-container { |
|
|
width: 100%; |
|
|
max-width: 1000px; |
|
|
background: rgba(255, 255, 255, 0.95); |
|
|
border-radius: 20px; |
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); |
|
|
padding: 30px; |
|
|
} |
|
|
|
|
|
.header { |
|
|
text-align: center; |
|
|
margin-bottom: 30px; |
|
|
} |
|
|
|
|
|
.header h1 { |
|
|
color: #667eea; |
|
|
font-size: 2.5em; |
|
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1); |
|
|
} |
|
|
|
|
|
.score-board { |
|
|
display: flex; |
|
|
justify-content: space-around; |
|
|
margin-bottom: 30px; |
|
|
padding: 15px; |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
border-radius: 10px; |
|
|
color: white; |
|
|
} |
|
|
|
|
|
.score-item { |
|
|
text-align: center; |
|
|
} |
|
|
|
|
|
.score-item .label { |
|
|
font-size: 0.9em; |
|
|
opacity: 0.9; |
|
|
} |
|
|
|
|
|
.score-item .value { |
|
|
font-size: 2em; |
|
|
font-weight: bold; |
|
|
margin-top: 5px; |
|
|
} |
|
|
|
|
|
.game-area { |
|
|
position: relative; |
|
|
height: 400px; |
|
|
background: linear-gradient(to bottom, #87CEEB 0%, #98D8C8 100%); |
|
|
border-radius: 15px; |
|
|
overflow: hidden; |
|
|
margin-bottom: 20px; |
|
|
border: 3px solid #667eea; |
|
|
} |
|
|
|
|
|
.shooter { |
|
|
position: absolute; |
|
|
bottom: 20px; |
|
|
left: 50%; |
|
|
transform: translateX(-50%); |
|
|
font-size: 60px; |
|
|
transition: transform 0.1s; |
|
|
} |
|
|
|
|
|
.target { |
|
|
position: absolute; |
|
|
top: 20px; |
|
|
left: 50%; |
|
|
transform: translateX(-50%); |
|
|
font-size: 80px; |
|
|
animation: float 3s ease-in-out infinite; |
|
|
} |
|
|
|
|
|
@keyframes float { |
|
|
0%, 100% { transform: translateX(-50%) translateY(0px); } |
|
|
50% { transform: translateX(-50%) translateY(-20px); } |
|
|
} |
|
|
|
|
|
.bullet { |
|
|
position: absolute; |
|
|
width: 8px; |
|
|
height: 20px; |
|
|
background: linear-gradient(to top, #ff6b6b, #feca57); |
|
|
border-radius: 4px; |
|
|
bottom: 80px; |
|
|
left: 50%; |
|
|
transform: translateX(-50%); |
|
|
box-shadow: 0 0 10px rgba(255, 107, 107, 0.8); |
|
|
} |
|
|
|
|
|
.question-section { |
|
|
background: #f8f9fa; |
|
|
padding: 25px; |
|
|
border-radius: 15px; |
|
|
margin-bottom: 20px; |
|
|
border: 2px solid #e9ecef; |
|
|
} |
|
|
|
|
|
.question-text { |
|
|
font-size: 1.3em; |
|
|
color: #333; |
|
|
margin-bottom: 20px; |
|
|
line-height: 1.6; |
|
|
} |
|
|
|
|
|
.answer-input { |
|
|
width: 100%; |
|
|
padding: 15px; |
|
|
font-size: 1.2em; |
|
|
border: 2px solid #ddd; |
|
|
border-radius: 10px; |
|
|
transition: all 0.3s; |
|
|
margin-bottom: 15px; |
|
|
} |
|
|
|
|
|
.answer-input:focus { |
|
|
outline: none; |
|
|
border-color: #667eea; |
|
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); |
|
|
} |
|
|
|
|
|
.answer-input.correct { |
|
|
border-color: #51cf66; |
|
|
background: #d3f9d8; |
|
|
} |
|
|
|
|
|
.answer-input.incorrect { |
|
|
border-color: #ff6b6b; |
|
|
background: #ffe0e0; |
|
|
} |
|
|
|
|
|
.button-group { |
|
|
display: flex; |
|
|
gap: 15px; |
|
|
justify-content: center; |
|
|
} |
|
|
|
|
|
.btn { |
|
|
padding: 15px 40px; |
|
|
font-size: 1.1em; |
|
|
border: none; |
|
|
border-radius: 10px; |
|
|
cursor: pointer; |
|
|
font-weight: bold; |
|
|
transition: all 0.3s; |
|
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); |
|
|
} |
|
|
|
|
|
.btn-shoot { |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
color: white; |
|
|
} |
|
|
|
|
|
.btn-shoot:hover { |
|
|
transform: translateY(-2px); |
|
|
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); |
|
|
} |
|
|
|
|
|
.btn-shoot:disabled { |
|
|
background: #ccc; |
|
|
cursor: not-allowed; |
|
|
transform: none; |
|
|
} |
|
|
|
|
|
.btn-next { |
|
|
background: linear-gradient(135deg, #feca57 0%, #ff9ff3 100%); |
|
|
color: white; |
|
|
} |
|
|
|
|
|
.btn-next:hover { |
|
|
transform: translateY(-2px); |
|
|
box-shadow: 0 6px 20px rgba(254, 202, 87, 0.4); |
|
|
} |
|
|
|
|
|
.feedback { |
|
|
text-align: center; |
|
|
margin-top: 15px; |
|
|
font-size: 1.2em; |
|
|
font-weight: bold; |
|
|
min-height: 30px; |
|
|
} |
|
|
|
|
|
.feedback.correct { |
|
|
color: #51cf66; |
|
|
} |
|
|
|
|
|
.feedback.incorrect { |
|
|
color: #ff6b6b; |
|
|
} |
|
|
|
|
|
.loading { |
|
|
text-align: center; |
|
|
padding: 20px; |
|
|
font-size: 1.2em; |
|
|
color: #667eea; |
|
|
} |
|
|
|
|
|
.explosion { |
|
|
position: absolute; |
|
|
font-size: 100px; |
|
|
animation: explode 0.5s ease-out; |
|
|
} |
|
|
|
|
|
@keyframes explode { |
|
|
0% { |
|
|
transform: scale(0); |
|
|
opacity: 1; |
|
|
} |
|
|
100% { |
|
|
transform: scale(2); |
|
|
opacity: 0; |
|
|
} |
|
|
} |
|
|
|
|
|
@keyframes shoot { |
|
|
0% { |
|
|
bottom: 80px; |
|
|
opacity: 1; |
|
|
} |
|
|
100% { |
|
|
bottom: 400px; |
|
|
opacity: 0; |
|
|
} |
|
|
} |
|
|
|
|
|
@keyframes recoil { |
|
|
0%, 100% { transform: translateX(-50%) translateY(0); } |
|
|
50% { transform: translateX(-50%) translateY(10px); } |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="game-container"> |
|
|
<div class="header"> |
|
|
<h1>🎯 English Shooting Game</h1> |
|
|
</div> |
|
|
|
|
|
<div class="score-board"> |
|
|
<div class="score-item"> |
|
|
<div class="label">Điểm</div> |
|
|
<div class="value" id="score">0</div> |
|
|
</div> |
|
|
<div class="score-item"> |
|
|
<div class="label">Đúng</div> |
|
|
<div class="value" id="correct">0</div> |
|
|
</div> |
|
|
<div class="score-item"> |
|
|
<div class="label">Sai</div> |
|
|
<div class="value" id="incorrect">0</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="game-area" id="gameArea"> |
|
|
<div class="shooter">🔫</div> |
|
|
<div class="target">🎯</div> |
|
|
</div> |
|
|
|
|
|
<div class="question-section" id="questionSection"> |
|
|
<div class="loading">Đang tải câu hỏi...</div> |
|
|
</div> |
|
|
|
|
|
<div class="feedback" id="feedback"></div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
let score = 0; |
|
|
let correctCount = 0; |
|
|
let incorrectCount = 0; |
|
|
let currentAnswer = ''; |
|
|
let isAnswered = false; |
|
|
|
|
|
async function generateQuestion() { |
|
|
const questionSection = document.getElementById('questionSection'); |
|
|
questionSection.innerHTML = '<div class="loading">Đang tải câu hỏi...</div>'; |
|
|
document.getElementById('feedback').textContent = ''; |
|
|
isAnswered = false; |
|
|
|
|
|
try { |
|
|
const response = await fetch('/api/question'); |
|
|
const data = await response.json(); |
|
|
|
|
|
if (data.error) { |
|
|
throw new Error(data.error); |
|
|
} |
|
|
|
|
|
currentAnswer = data.answer.toLowerCase(); |
|
|
|
|
|
questionSection.innerHTML = ` |
|
|
<div class="question-text">${data.sentence}</div> |
|
|
<input type="text" class="answer-input" id="answerInput" placeholder="Nhập từ vào đây..."> |
|
|
<div class="button-group"> |
|
|
<button class="btn btn-shoot" onclick="checkAndShoot()">🔫 Bắn!</button> |
|
|
<button class="btn btn-next" onclick="generateQuestion()">⏭️ Câu mới</button> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
document.getElementById('answerInput').addEventListener('keypress', (e) => { |
|
|
if (e.key === 'Enter') checkAndShoot(); |
|
|
}); |
|
|
document.getElementById('answerInput').focus(); |
|
|
} catch (error) { |
|
|
console.error('Error:', error); |
|
|
questionSection.innerHTML = '<div class="loading">❌ Lỗi tải câu hỏi. Vui lòng thử lại!</div>'; |
|
|
setTimeout(generateQuestion, 2000); |
|
|
} |
|
|
} |
|
|
|
|
|
function checkAndShoot() { |
|
|
if (isAnswered) return; |
|
|
|
|
|
const input = document.getElementById('answerInput'); |
|
|
const userAnswer = input.value.trim().toLowerCase(); |
|
|
const feedback = document.getElementById('feedback'); |
|
|
|
|
|
if (!userAnswer) { |
|
|
feedback.textContent = '⚠️ Vui lòng nhập câu trả lời!'; |
|
|
feedback.className = 'feedback'; |
|
|
return; |
|
|
} |
|
|
|
|
|
isAnswered = true; |
|
|
const isCorrect = userAnswer === currentAnswer; |
|
|
|
|
|
if (isCorrect) { |
|
|
input.className = 'answer-input correct'; |
|
|
feedback.textContent = '✅ Chính xác! Bắn đạn!'; |
|
|
feedback.className = 'feedback correct'; |
|
|
score += 10; |
|
|
correctCount++; |
|
|
shootBullet(true); |
|
|
} else { |
|
|
input.className = 'answer-input incorrect'; |
|
|
feedback.textContent = `❌ Sai rồi! Đáp án đúng là: "${currentAnswer}"`; |
|
|
feedback.className = 'feedback incorrect'; |
|
|
incorrectCount++; |
|
|
shootBullet(false); |
|
|
} |
|
|
|
|
|
updateScoreboard(); |
|
|
input.disabled = true; |
|
|
} |
|
|
|
|
|
function shootBullet(hit) { |
|
|
const gameArea = document.getElementById('gameArea'); |
|
|
const shooter = gameArea.querySelector('.shooter'); |
|
|
|
|
|
shooter.style.animation = 'recoil 0.3s'; |
|
|
setTimeout(() => { |
|
|
shooter.style.animation = ''; |
|
|
}, 300); |
|
|
|
|
|
if (hit) { |
|
|
const bullet = document.createElement('div'); |
|
|
bullet.className = 'bullet'; |
|
|
gameArea.appendChild(bullet); |
|
|
|
|
|
bullet.style.animation = 'shoot 0.8s ease-out'; |
|
|
|
|
|
setTimeout(() => { |
|
|
const target = gameArea.querySelector('.target'); |
|
|
const explosion = document.createElement('div'); |
|
|
explosion.className = 'explosion'; |
|
|
explosion.textContent = '💥'; |
|
|
explosion.style.left = target.offsetLeft + 'px'; |
|
|
explosion.style.top = target.offsetTop + 'px'; |
|
|
gameArea.appendChild(explosion); |
|
|
|
|
|
setTimeout(() => { |
|
|
explosion.remove(); |
|
|
}, 500); |
|
|
|
|
|
bullet.remove(); |
|
|
}, 800); |
|
|
} |
|
|
} |
|
|
|
|
|
function updateScoreboard() { |
|
|
document.getElementById('score').textContent = score; |
|
|
document.getElementById('correct').textContent = correctCount; |
|
|
document.getElementById('incorrect').textContent = incorrectCount; |
|
|
} |
|
|
|
|
|
generateQuestion(); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
|
|
|
|
|
|
FALLBACK_QUESTIONS = [ |
|
|
{"sentence": "I ___ to school every day.", "answer": "go"}, |
|
|
{"sentence": "She ___ a book yesterday.", "answer": "read"}, |
|
|
{"sentence": "They are ___ soccer now.", "answer": "playing"}, |
|
|
{"sentence": "He ___ his homework last night.", "answer": "did"}, |
|
|
{"sentence": "We ___ going to the park tomorrow.", "answer": "are"}, |
|
|
{"sentence": "The cat ___ on the table.", "answer": "is"}, |
|
|
{"sentence": "I have ___ this movie before.", "answer": "seen"}, |
|
|
{"sentence": "She can ___ English very well.", "answer": "speak"}, |
|
|
{"sentence": "They ___ to Paris last year.", "answer": "went"}, |
|
|
{"sentence": "He ___ pizza for dinner.", "answer": "likes"}, |
|
|
] |
|
|
|
|
|
@app.route('/') |
|
|
def home(): |
|
|
return render_template_string(HTML_TEMPLATE) |
|
|
|
|
|
@app.route('/api/question') |
|
|
def get_question(): |
|
|
try: |
|
|
|
|
|
if HF_TOKEN: |
|
|
client = InferenceClient(token=HF_TOKEN) |
|
|
|
|
|
prompt = """Create one English fill-in-the-blank question. Format: |
|
|
SENTENCE: [sentence with ONE ___ for blank] |
|
|
ANSWER: [correct word] |
|
|
|
|
|
Example: |
|
|
SENTENCE: I ___ to school every day. |
|
|
ANSWER: go |
|
|
|
|
|
Create a new question:""" |
|
|
|
|
|
response = client.text_generation( |
|
|
prompt, |
|
|
model="mistralai/Mixtral-8x7B-Instruct-v0.1", |
|
|
max_new_tokens=100, |
|
|
temperature=0.9 |
|
|
) |
|
|
|
|
|
|
|
|
lines = response.strip().split('\n') |
|
|
sentence = "" |
|
|
answer = "" |
|
|
|
|
|
for line in lines: |
|
|
if line.startswith('SENTENCE:'): |
|
|
sentence = line.replace('SENTENCE:', '').strip() |
|
|
elif line.startswith('ANSWER:'): |
|
|
answer = line.replace('ANSWER:', '').strip() |
|
|
|
|
|
if sentence and answer and '___' in sentence: |
|
|
return jsonify({ |
|
|
'sentence': sentence, |
|
|
'answer': answer |
|
|
}) |
|
|
except Exception as e: |
|
|
print(f"AI generation failed: {e}") |
|
|
|
|
|
|
|
|
question = random.choice(FALLBACK_QUESTIONS) |
|
|
return jsonify(question) |
|
|
|
|
|
if __name__ == '__main__': |
|
|
port = int(os.environ.get('PORT', 7860)) |
|
|
app.run(host='0.0.0.0', port=port, debug=False) |