Spaces:
Running
Running
milwright
Claude
commited on
Commit
·
86f177c
1
Parent(s):
6a1d845
simplify game mechanics and consolidate to single AI model
Browse files- Remove two-passage-per-round system; one passage per round with immediate level advancement on pass
- Consolidate from dual-model to single Gemma-3-27b model for all operations (word selection, hints, contextualization)
- Remove round number from progress display; show only "Level X • Y blank(s)"
- Integrate leaderboard system with milestone notifications
- Update welcome overlay with concise gameplay description
- Rewrite README with conceptual framework exploring convergence of educational assessment and masked language modeling
- Refine contextualization prompts for clearer, more direct AI responses
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <[email protected]>
- README.md +54 -18
- index.html +14 -4
- src/aiService.js +14 -14
- src/app.js +64 -29
- src/clozeGameEngine.js +92 -308
- src/leaderboardService.js +362 -0
- src/leaderboardUI.js +433 -0
- src/styles.css +488 -4
- src/welcomeOverlay.js +5 -5
README.md
CHANGED
|
@@ -11,33 +11,69 @@ thumbnail: >-
|
|
| 11 |
|
| 12 |
# Cloze Reader
|
| 13 |
|
| 14 |
-
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
-
-
|
| 19 |
-
- **Smart Hints**: Get word length, first letter, and contextual clues
|
| 20 |
-
- **AI Chat Help**: Click 💬 for intelligent hints about any blank
|
| 21 |
-
- **Historical and Literary Passages**: Randomly excerpted texts from Project Gutenberg's collection
|
| 22 |
-
- **Level-Appropriate Challenges**: Hints adapt based on your current level
|
| 23 |
|
| 24 |
-
|
| 25 |
|
| 26 |
-
|
| 27 |
-
2. Fill in the blank(s) with appropriate words
|
| 28 |
-
3. Use hints or chat help if needed
|
| 29 |
-
4. Submit to see your results and advance levels
|
| 30 |
-
5. Continue practicing with new passages
|
| 31 |
|
| 32 |
-
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
## Technology
|
| 39 |
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
## Running Locally with Docker
|
| 43 |
|
|
|
|
| 11 |
|
| 12 |
# Cloze Reader
|
| 13 |
|
| 14 |
+
## When Assessment Becomes Training Data, and Training Data Becomes Assessment
|
| 15 |
|
| 16 |
+
In 1953, Wilson Taylor proposed the "cloze procedure" as a measurement tool for reading comprehension and text difficulty. The method was elegantly simple: delete every nth word from a passage, ask readers to fill in the blanks, and score their accuracy. Taylor argued that successful completion demonstrated not mere vocabulary knowledge but genuine contextual understanding—the ability to infer meaning from surrounding linguistic patterns. By the 1960s, cloze testing had become standard in educational assessment, literacy research, and language teaching. Its appeal lay in its objectivity: unlike essay grading, cloze scores could be automated, quantified, compared across populations.
|
| 17 |
|
| 18 |
+
Sixty-five years later, Google researchers published "BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding." BERT's innovation was masked language modeling: randomly mask 15% of tokens in a text, train the model to predict the missing words. The researchers didn't cite Taylor. They didn't frame this as a pedagogical technique. Yet they had independently reinvented the cloze procedure as a machine learning objective. BERT learned language by solving millions of fill-in-the-blank exercises, training on the same task that had measured human comprehension since the Eisenhower administration.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
This convergence wasn't coincidental. Both cloze testing and masked language modeling operate on the same premise: understanding language means predicting from context. If you can accurately fill in blanks, you've demonstrated comprehension—whether you're a student in 1960s Kansas or a transformer model in 2018. The methodology traveled from educational psychology to computational linguistics because it captured something fundamental about how meaning works: language is redundant, predictable, inferable from surrounding structure.
|
| 21 |
|
| 22 |
+
Both approaches also share the same constraints. Educational researchers identified "clozability"—the phenomenon that some words are easier to predict than others due to context salience, limited synonyms, or statistical frequency. Machine learning researchers independently discovered the same issue: certain tokens are trivially predictable from context, while others require deeper reasoning. Zhang & Hashimoto (2021) showed that masked language models learn statistical and syntactic dependencies—exactly what cloze tests aim to measure in humans. The parallel is not superficial.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
+
But now the loop has closed in an unexpected way. BERT and its descendants—including Google's Gemma models—were trained on masked prediction tasks extracted from web text, books, Wikipedia. These models learned to predict missing words from billions of cloze-like exercises. Now, with Cloze Reader, those same models generate cloze tests for humans. The AI that learned language by filling in blanks now decides which blanks you should fill in. The training methodology has become the assessment tool, and the assessment tool has become training data.
|
| 25 |
|
| 26 |
+
## What This Game Explores
|
| 27 |
+
|
| 28 |
+
Cloze Reader uses open-weight Gemma-3 models to transform Project Gutenberg literature into dynamically generated cloze exercises. The 12B parameter model scans passages and selects vocabulary to remove. The 27B model generates contextual hints and provides conversational guidance. Every passage is fresh, every blank algorithmically chosen, every hint synthesized in real time.
|
| 29 |
+
|
| 30 |
+
This isn't just automated test generation. It's an investigation into what happens when the twin histories of educational assessment and machine learning collapse into each other. Consider:
|
| 31 |
+
|
| 32 |
+
**Standardization vs. Serendipity:** Educational cloze tests sought standardization—predictable difficulty, comparable scores, systematic progression. Machine learning cloze tasks sought diversity—randomized masking, varied contexts, statistical coverage. Using Gemma models on Project Gutenberg's 70,000-book corpus introduces radical serendipity: you might encounter Victorian Gothic prose, 1920s adventure serials, obscure 19th-century essays, forgotten feminist manifestos. The algorithm selects passages and words through statistical patterns trained on internet-scale text, not curriculum design. What does assessment mean when no human predetermined what counts as "appropriate difficulty"?
|
| 33 |
+
|
| 34 |
+
**Inference vs. Memorization:** Traditional cloze tests measured whether students could infer from context rather than recall from memory. Educational researchers have long critiqued cloze procedures for measuring primarily local, sentence-level inference (surface coherence) rather than global text structure or pragmatic reasoning. Machine learning critics make parallel arguments: masked language models exploit spurious statistical regularities rather than genuine semantic understanding. When a model trained on surface patterns generates tests, and humans solve those tests using similar heuristics, where is comprehension actually happening? The distinction between understanding and statistical correlation becomes harder to maintain on both sides.
|
| 35 |
+
|
| 36 |
+
**Automation and Authority:** Educational assessment historically required human expertise—teachers selecting texts, choosing appropriate blanks, evaluating answers. Automated testing promised efficiency but was criticized for reducing learning to quantifiable metrics. Now the automation is complete: an algorithmic system with no pedagogical training, no curriculum knowledge, no understanding of individual learners generates and evaluates exercises. It runs on open-weight models anyone can download, modify, or interrogate. What happens to authority over what constitutes "correct" reading comprehension when assessment moves from institutional gatekeeping to open algorithmic systems?
|
| 37 |
+
|
| 38 |
+
**The Feedback Loop:** Most critically, this is a recursive system. Gemma models were trained partly on digitized books—including many from Project Gutenberg. The texts they learned from become the texts they generate exercises from. The model learned language patterns from Victorian literature, then uses those patterns to test human understanding of Victorian literature. Meanwhile, interactions with this game could theoretically become training data for future models. Assessment data becomes training data becomes assessment tools becomes training data. At what point does the distinction between learning and evaluation dissolve entirely?
|
| 39 |
+
|
| 40 |
+
**The Exact-Word Problem:** Educational cloze testing has long debated whether to accept only exact matches or score semantic/grammatical equivalents (synonyms, morphological variants). This game enforces exact-word matching with some suffix normalization, mirroring how masked language models are trained on exact token prediction. Both approaches may penalize valid alternatives. When you type "sad" but the answer was "melancholy," have you failed to comprehend the passage—or simply chosen a different word from the same semantic field? This scoring problem exists identically in human assessment and algorithmic evaluation.
|
| 41 |
+
|
| 42 |
+
## The Research Context
|
| 43 |
+
|
| 44 |
+
Recent scholarship explicitly bridges cloze assessment and masked language modeling:
|
| 45 |
+
|
| 46 |
+
- **Matsumori et al. (2023)** built CLOZER, a system using masked language models to generate open cloze questions for L2 English learners, demonstrating practical pedagogical applications
|
| 47 |
+
- **Ondov et al. (2024, NAACL)** argue: "The cloze training objective of Masked Language Models makes them a natural choice for generating plausible distractors for human cloze questions"
|
| 48 |
+
- **Zhang & Hashimoto (2021)** analyzed the inductive biases of masked tokens, showing that models learn statistical and syntactic dependencies through the same mechanisms cloze tests measure in humans
|
| 49 |
+
|
| 50 |
+
This project sits at the intersection of these research trajectories—using the tools that now generate assessments to explore what happens when the boundary between human learning and machine training dissolves.
|
| 51 |
+
|
| 52 |
+
## A Game, Not a Conclusion
|
| 53 |
+
|
| 54 |
+
Cloze Reader doesn't resolve these tensions. It stages them. Through vintage aesthetics and classic texts, it creates a space where the convergence of educational assessment and machine learning becomes palpable. You're playing a literacy game designed by an algorithm that learned literacy by playing the same game billions of times. Every passage is a historical text processed by a model trained on historical texts. Every hint comes from a system that doesn't "understand" in any human sense but can nonetheless guide you toward understanding.
|
| 55 |
+
|
| 56 |
+
The experience raises more questions than it answers. Is this pedagogy or pattern replication? Assessment or performance? Human learning or collaborative prediction with a statistical engine? These aren't rhetorical questions—they're open empirical questions about what education looks like when the tools we use to measure learning are built from the same processes we're trying to measure.
|
| 57 |
+
|
| 58 |
+
## How It Works
|
| 59 |
+
|
| 60 |
+
**Dual-Model Architecture:** The system uses two Gemma-3 models with distinct roles. The 12B parameter model analyzes passages and selects words to mask based on patterns learned during its training. The 27B model generates contextual hints and powers the chat interface. This separation mirrors the distinction between assessment design and pedagogical guidance—though both are algorithmic.
|
| 61 |
+
|
| 62 |
+
**Progressive Levels:** The game implements a level system (1-5 with 1 blank, 6-10 with 2 blanks, 11+ with 3 blanks) that scaffolds difficulty through word length constraints, historical period selection, and hint disclosure. Early levels use 1900s texts and show first+last letters; advanced levels draw from any era and provide only first letters. Each round presents two passages from different books, requiring consistent performance across rounds before advancing.
|
| 63 |
+
|
| 64 |
+
**Serendipitous Selection:** Passages stream directly from Hugging Face's Project Gutenberg dataset. The model selects words based on its training rather than curricular logic—sometimes choosing obvious vocabulary, sometimes obscure terms, sometimes generating exercises that are trivially easy or frustratingly hard. This unpredictability is a feature: it reveals how algorithmic assessment differs from human-designed pedagogy.
|
| 65 |
+
|
| 66 |
+
**Chat as Scaffold:** Click the 💬 icon beside any blank to engage the 27B model in conversation. It attempts to guide you through Socratic questioning, semantic clues, and contextual hints—replicating what a tutor might do, constrained by what a language model trained on text prediction can actually accomplish.
|
| 67 |
+
|
| 68 |
+
The system filters out dictionaries, technical documentation, and poetry—ensuring narrative prose where blanks are theoretically inferable from context, even if the model's choices sometimes suggest otherwise.
|
| 69 |
|
| 70 |
## Technology
|
| 71 |
|
| 72 |
+
**Vanilla JavaScript, No Build Step:** The application runs entirely in the browser using ES6 modules—no webpack, no bundler, no compilation. This architectural choice mirrors the project's conceptual interests: keeping the machinery visible and modifiable rather than obscured behind layers of tooling. A minimal FastAPI backend serves static files and injects API keys; everything else happens client-side.
|
| 73 |
+
|
| 74 |
+
**Open-Weight Models:** Uses Google's Gemma-3 models (12B and 27B parameters) via OpenRouter, or alternatively connects to local LLM servers (LM Studio, etc.) on port 1234. The choice of open-weight models is deliberate: these systems can be downloaded, inspected, run locally, modified. When assessment becomes algorithmic, transparency about the algorithm matters. You can examine exactly which model is generating your exercises, run the same models yourself, experiment with alternatives.
|
| 75 |
+
|
| 76 |
+
**Streaming from Public Archives:** Book data streams directly from Hugging Face's mirror of Project Gutenberg's corpus—public domain texts, open dataset infrastructure, no proprietary content libraries. The entire pipeline from literature to exercises relies on openly accessible resources, making the system reproducible and auditable.
|
| 77 |
|
| 78 |
## Running Locally with Docker
|
| 79 |
|
index.html
CHANGED
|
@@ -12,11 +12,21 @@
|
|
| 12 |
<body class="min-h-screen">
|
| 13 |
<div id="app" class="container mx-auto px-4 py-8 max-w-4xl">
|
| 14 |
<header class="text-center mb-8">
|
| 15 |
-
<div class="flex items-
|
| 16 |
-
<
|
| 17 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
</div>
|
| 19 |
-
<p class="typewriter-subtitle">Fill in the blanks to practice reading comprehension</p>
|
| 20 |
</header>
|
| 21 |
|
| 22 |
<main id="game-container" class="space-y-6">
|
|
|
|
| 12 |
<body class="min-h-screen">
|
| 13 |
<div id="app" class="container mx-auto px-4 py-8 max-w-4xl">
|
| 14 |
<header class="text-center mb-8">
|
| 15 |
+
<div class="flex items-start justify-between mb-4 gap-4">
|
| 16 |
+
<div class="flex-1 min-w-0"></div>
|
| 17 |
+
<div class="flex flex-col items-center gap-3">
|
| 18 |
+
<div class="flex items-center gap-3">
|
| 19 |
+
<img src="./icon.png" alt="Cloze Reader" class="w-12 h-12">
|
| 20 |
+
<h1 class="text-4xl font-bold typewriter-text">Cloze Reader</h1>
|
| 21 |
+
</div>
|
| 22 |
+
<p class="typewriter-subtitle">Fill in the blanks to practice reading comprehension</p>
|
| 23 |
+
</div>
|
| 24 |
+
<div class="flex-1 min-w-0 flex justify-end items-end">
|
| 25 |
+
<button id="leaderboard-btn" class="typewriter-button-small" title="View leaderboard">
|
| 26 |
+
Leaderboard
|
| 27 |
+
</button>
|
| 28 |
+
</div>
|
| 29 |
</div>
|
|
|
|
| 30 |
</header>
|
| 31 |
|
| 32 |
<main id="game-container" class="space-y-6">
|
src/aiService.js
CHANGED
|
@@ -5,9 +5,9 @@ class OpenRouterService {
|
|
| 5 |
this.apiUrl = this.isLocalMode ? 'http://localhost:1234/v1/chat/completions' : 'https://openrouter.ai/api/v1/chat/completions';
|
| 6 |
this.apiKey = this.getApiKey();
|
| 7 |
|
| 8 |
-
//
|
| 9 |
this.hintModel = this.isLocalMode ? 'gemma-3-12b' : 'google/gemma-3-27b-it';
|
| 10 |
-
this.primaryModel = this.isLocalMode ? 'gemma-3-12b' : 'google/gemma-3-
|
| 11 |
this.model = this.primaryModel; // Default model for backward compatibility
|
| 12 |
|
| 13 |
console.log('AI Service initialized:', {
|
|
@@ -700,20 +700,20 @@ Return JSON: {"passage1": {"words": [${blanksPerPassage} words], "context": "one
|
|
| 700 |
}
|
| 701 |
}
|
| 702 |
|
| 703 |
-
async generateContextualization(title, author) {
|
| 704 |
console.log('generateContextualization called for:', title, 'by', author);
|
| 705 |
-
|
| 706 |
// Check for API key at runtime
|
| 707 |
const currentKey = this.getApiKey();
|
| 708 |
if (currentKey && !this.apiKey) {
|
| 709 |
this.apiKey = currentKey;
|
| 710 |
}
|
| 711 |
-
|
| 712 |
console.log('API key available for contextualization:', !!this.apiKey);
|
| 713 |
-
|
| 714 |
if (!this.apiKey) {
|
| 715 |
console.log('No API key, returning fallback contextualization');
|
| 716 |
-
return
|
| 717 |
}
|
| 718 |
|
| 719 |
try {
|
|
@@ -727,16 +727,16 @@ Return JSON: {"passage1": {"words": [${blanksPerPassage} words], "context": "one
|
|
| 727 |
'X-Title': 'Cloze Reader'
|
| 728 |
},
|
| 729 |
body: JSON.stringify({
|
| 730 |
-
model: this.primaryModel, // Use Gemma-3-
|
| 731 |
messages: [{
|
| 732 |
role: 'system',
|
| 733 |
-
content: '
|
| 734 |
}, {
|
| 735 |
role: 'user',
|
| 736 |
-
content: `
|
| 737 |
}],
|
| 738 |
max_tokens: 150,
|
| 739 |
-
temperature: 0.
|
| 740 |
response_format: { type: "text" }
|
| 741 |
})
|
| 742 |
});
|
|
@@ -783,11 +783,11 @@ Return JSON: {"passage1": {"words": [${blanksPerPassage} words], "context": "one
|
|
| 783 |
}
|
| 784 |
|
| 785 |
content = content.trim();
|
| 786 |
-
|
| 787 |
// Clean up AI response artifacts
|
| 788 |
content = content
|
| 789 |
.replace(/^\s*["']|["']\s*$/g, '') // Remove leading/trailing quotes
|
| 790 |
-
.replace(/^\s*[
|
| 791 |
.replace(/\*+/g, '') // Remove asterisks (markdown bold/italic)
|
| 792 |
.replace(/_+/g, '') // Remove underscores (markdown)
|
| 793 |
.replace(/#+\s*/g, '') // Remove hash symbols (markdown headers)
|
|
@@ -799,7 +799,7 @@ Return JSON: {"passage1": {"words": [${blanksPerPassage} words], "context": "one
|
|
| 799 |
});
|
| 800 |
} catch (error) {
|
| 801 |
console.error('Error getting contextualization:', error);
|
| 802 |
-
return
|
| 803 |
}
|
| 804 |
}
|
| 805 |
|
|
|
|
| 5 |
this.apiUrl = this.isLocalMode ? 'http://localhost:1234/v1/chat/completions' : 'https://openrouter.ai/api/v1/chat/completions';
|
| 6 |
this.apiKey = this.getApiKey();
|
| 7 |
|
| 8 |
+
// Single model configuration: Gemma-3-27b for all operations
|
| 9 |
this.hintModel = this.isLocalMode ? 'gemma-3-12b' : 'google/gemma-3-27b-it';
|
| 10 |
+
this.primaryModel = this.isLocalMode ? 'gemma-3-12b' : 'google/gemma-3-27b-it';
|
| 11 |
this.model = this.primaryModel; // Default model for backward compatibility
|
| 12 |
|
| 13 |
console.log('AI Service initialized:', {
|
|
|
|
| 700 |
}
|
| 701 |
}
|
| 702 |
|
| 703 |
+
async generateContextualization(title, author, passage) {
|
| 704 |
console.log('generateContextualization called for:', title, 'by', author);
|
| 705 |
+
|
| 706 |
// Check for API key at runtime
|
| 707 |
const currentKey = this.getApiKey();
|
| 708 |
if (currentKey && !this.apiKey) {
|
| 709 |
this.apiKey = currentKey;
|
| 710 |
}
|
| 711 |
+
|
| 712 |
console.log('API key available for contextualization:', !!this.apiKey);
|
| 713 |
+
|
| 714 |
if (!this.apiKey) {
|
| 715 |
console.log('No API key, returning fallback contextualization');
|
| 716 |
+
return `A passage from ${author}'s "${title}"`;
|
| 717 |
}
|
| 718 |
|
| 719 |
try {
|
|
|
|
| 727 |
'X-Title': 'Cloze Reader'
|
| 728 |
},
|
| 729 |
body: JSON.stringify({
|
| 730 |
+
model: this.primaryModel, // Use Gemma-3-27b for contextualization
|
| 731 |
messages: [{
|
| 732 |
role: 'system',
|
| 733 |
+
content: 'Provide a single contextual insight about the passage: historical context, literary technique, thematic observation, or relevant fact. Be specific and direct. Maximum 25 words. Do not use dashes or em-dashes. Output ONLY the insight itself with no preamble, acknowledgments, or meta-commentary.'
|
| 734 |
}, {
|
| 735 |
role: 'user',
|
| 736 |
+
content: `From "${title}" by ${author}:\n\n${passage}`
|
| 737 |
}],
|
| 738 |
max_tokens: 150,
|
| 739 |
+
temperature: 0.7,
|
| 740 |
response_format: { type: "text" }
|
| 741 |
})
|
| 742 |
});
|
|
|
|
| 783 |
}
|
| 784 |
|
| 785 |
content = content.trim();
|
| 786 |
+
|
| 787 |
// Clean up AI response artifacts
|
| 788 |
content = content
|
| 789 |
.replace(/^\s*["']|["']\s*$/g, '') // Remove leading/trailing quotes
|
| 790 |
+
.replace(/^\s*[:;.!?]+\s*/, '') // Remove leading punctuation
|
| 791 |
.replace(/\*+/g, '') // Remove asterisks (markdown bold/italic)
|
| 792 |
.replace(/_+/g, '') // Remove underscores (markdown)
|
| 793 |
.replace(/#+\s*/g, '') // Remove hash symbols (markdown headers)
|
|
|
|
| 799 |
});
|
| 800 |
} catch (error) {
|
| 801 |
console.error('Error getting contextualization:', error);
|
| 802 |
+
return `A passage from ${author}'s "${title}"`;
|
| 803 |
}
|
| 804 |
}
|
| 805 |
|
src/app.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
| 2 |
import ClozeGame from './clozeGameEngine.js';
|
| 3 |
import ChatUI from './chatInterface.js';
|
| 4 |
import WelcomeOverlay from './welcomeOverlay.js';
|
|
|
|
| 5 |
|
| 6 |
class App {
|
| 7 |
constructor() {
|
| 8 |
this.game = new ClozeGame();
|
| 9 |
this.chatUI = new ChatUI(this.game);
|
| 10 |
this.welcomeOverlay = new WelcomeOverlay();
|
|
|
|
| 11 |
this.elements = {
|
| 12 |
loading: document.getElementById('loading'),
|
| 13 |
gameArea: document.getElementById('game-area'),
|
|
@@ -21,9 +23,10 @@ class App {
|
|
| 21 |
submitBtn: document.getElementById('submit-btn'),
|
| 22 |
nextBtn: document.getElementById('next-btn'),
|
| 23 |
hintBtn: document.getElementById('hint-btn'),
|
| 24 |
-
result: document.getElementById('result')
|
|
|
|
| 25 |
};
|
| 26 |
-
|
| 27 |
this.currentResults = null;
|
| 28 |
this.setupEventListeners();
|
| 29 |
}
|
|
@@ -44,7 +47,14 @@ class App {
|
|
| 44 |
this.elements.submitBtn.addEventListener('click', () => this.handleSubmit());
|
| 45 |
this.elements.nextBtn.addEventListener('click', () => this.handleNext());
|
| 46 |
this.elements.hintBtn.addEventListener('click', () => this.toggleHints());
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
// Allow Enter key to submit when focused on an input
|
| 49 |
document.addEventListener('keydown', (e) => {
|
| 50 |
if (e.key === 'Enter' && e.target.classList.contains('cloze-input')) {
|
|
@@ -72,9 +82,8 @@ class App {
|
|
| 72 |
|
| 73 |
// Show level information
|
| 74 |
const blanksCount = roundData.blanks.length;
|
| 75 |
-
const
|
| 76 |
-
|
| 77 |
-
|
| 78 |
this.elements.roundInfo.innerHTML = levelInfo;
|
| 79 |
|
| 80 |
// Show contextualization from AI agent
|
|
@@ -157,13 +166,19 @@ class App {
|
|
| 157 |
|
| 158 |
displayResults(results) {
|
| 159 |
let message = `Score: ${results.correct}/${results.total}`;
|
| 160 |
-
|
| 161 |
if (results.passed) {
|
| 162 |
// Check if level was just advanced
|
| 163 |
if (results.justAdvancedLevel) {
|
| 164 |
message += ` ✓ Level ${results.currentLevel} unlocked!`;
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
} else {
|
| 168 |
message += ` ✓ Passed`;
|
| 169 |
}
|
|
@@ -172,12 +187,12 @@ class App {
|
|
| 172 |
message += ` - Try again (need ${results.requiredCorrect}/${results.total})`;
|
| 173 |
this.elements.result.className = 'mt-4 text-center font-semibold text-red-600';
|
| 174 |
}
|
| 175 |
-
|
| 176 |
this.elements.result.textContent = message;
|
| 177 |
-
|
| 178 |
// Always reveal answers at the end of each round
|
| 179 |
this.revealAnswersInPlace(results.results);
|
| 180 |
-
|
| 181 |
// Show next button and hide submit button
|
| 182 |
this.elements.submitBtn.style.display = 'none';
|
| 183 |
this.elements.nextBtn.classList.remove('hidden');
|
|
@@ -205,35 +220,28 @@ class App {
|
|
| 205 |
try {
|
| 206 |
// Show loading immediately with specific message
|
| 207 |
this.showLoading(true, 'Loading passages...');
|
| 208 |
-
|
| 209 |
-
// Clear chat history when starting new
|
| 210 |
this.chatUI.clearChatHistory();
|
| 211 |
-
|
| 212 |
// Always show loading for at least 1 second for smooth UX
|
| 213 |
const startTime = Date.now();
|
| 214 |
-
|
| 215 |
-
//
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
// Load second passage in current round
|
| 219 |
-
roundData = await this.game.nextPassage();
|
| 220 |
-
} else {
|
| 221 |
-
// Load next round (two new passages)
|
| 222 |
-
roundData = await this.game.nextRound();
|
| 223 |
-
}
|
| 224 |
-
|
| 225 |
// Ensure loading is shown for at least half a second
|
| 226 |
const elapsedTime = Date.now() - startTime;
|
| 227 |
if (elapsedTime < 500) {
|
| 228 |
await new Promise(resolve => setTimeout(resolve, 500 - elapsedTime));
|
| 229 |
}
|
| 230 |
-
|
| 231 |
this.displayRound(roundData);
|
| 232 |
this.resetUI();
|
| 233 |
this.showLoading(false);
|
| 234 |
} catch (error) {
|
| 235 |
-
console.error('Error loading next
|
| 236 |
-
this.showError('Could not load next
|
| 237 |
}
|
| 238 |
}
|
| 239 |
|
|
@@ -329,6 +337,33 @@ class App {
|
|
| 329 |
this.elements.loading.classList.remove('hidden');
|
| 330 |
this.elements.gameArea.classList.add('hidden');
|
| 331 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
}
|
| 333 |
|
| 334 |
// Initialize the app when DOM is loaded
|
|
|
|
| 2 |
import ClozeGame from './clozeGameEngine.js';
|
| 3 |
import ChatUI from './chatInterface.js';
|
| 4 |
import WelcomeOverlay from './welcomeOverlay.js';
|
| 5 |
+
import { LeaderboardUI } from './leaderboardUI.js';
|
| 6 |
|
| 7 |
class App {
|
| 8 |
constructor() {
|
| 9 |
this.game = new ClozeGame();
|
| 10 |
this.chatUI = new ChatUI(this.game);
|
| 11 |
this.welcomeOverlay = new WelcomeOverlay();
|
| 12 |
+
this.leaderboardUI = new LeaderboardUI(this.game.leaderboardService);
|
| 13 |
this.elements = {
|
| 14 |
loading: document.getElementById('loading'),
|
| 15 |
gameArea: document.getElementById('game-area'),
|
|
|
|
| 23 |
submitBtn: document.getElementById('submit-btn'),
|
| 24 |
nextBtn: document.getElementById('next-btn'),
|
| 25 |
hintBtn: document.getElementById('hint-btn'),
|
| 26 |
+
result: document.getElementById('result'),
|
| 27 |
+
leaderboardBtn: document.getElementById('leaderboard-btn')
|
| 28 |
};
|
| 29 |
+
|
| 30 |
this.currentResults = null;
|
| 31 |
this.setupEventListeners();
|
| 32 |
}
|
|
|
|
| 47 |
this.elements.submitBtn.addEventListener('click', () => this.handleSubmit());
|
| 48 |
this.elements.nextBtn.addEventListener('click', () => this.handleNext());
|
| 49 |
this.elements.hintBtn.addEventListener('click', () => this.toggleHints());
|
| 50 |
+
|
| 51 |
+
// Leaderboard button
|
| 52 |
+
if (this.elements.leaderboardBtn) {
|
| 53 |
+
this.elements.leaderboardBtn.addEventListener('click', () => {
|
| 54 |
+
this.leaderboardUI.show();
|
| 55 |
+
});
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
// Allow Enter key to submit when focused on an input
|
| 59 |
document.addEventListener('keydown', (e) => {
|
| 60 |
if (e.key === 'Enter' && e.target.classList.contains('cloze-input')) {
|
|
|
|
| 82 |
|
| 83 |
// Show level information
|
| 84 |
const blanksCount = roundData.blanks.length;
|
| 85 |
+
const levelInfo = `Level ${this.game.currentLevel} • ${blanksCount} blank${blanksCount > 1 ? 's' : ''}`;
|
| 86 |
+
|
|
|
|
| 87 |
this.elements.roundInfo.innerHTML = levelInfo;
|
| 88 |
|
| 89 |
// Show contextualization from AI agent
|
|
|
|
| 166 |
|
| 167 |
displayResults(results) {
|
| 168 |
let message = `Score: ${results.correct}/${results.total}`;
|
| 169 |
+
|
| 170 |
if (results.passed) {
|
| 171 |
// Check if level was just advanced
|
| 172 |
if (results.justAdvancedLevel) {
|
| 173 |
message += ` ✓ Level ${results.currentLevel} unlocked!`;
|
| 174 |
+
|
| 175 |
+
// Check for milestone notification (every 5 levels)
|
| 176 |
+
if (results.currentLevel % 5 === 0) {
|
| 177 |
+
this.leaderboardUI.showMilestoneNotification(results.currentLevel);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
// Check for high score
|
| 181 |
+
this.checkForHighScore();
|
| 182 |
} else {
|
| 183 |
message += ` ✓ Passed`;
|
| 184 |
}
|
|
|
|
| 187 |
message += ` - Try again (need ${results.requiredCorrect}/${results.total})`;
|
| 188 |
this.elements.result.className = 'mt-4 text-center font-semibold text-red-600';
|
| 189 |
}
|
| 190 |
+
|
| 191 |
this.elements.result.textContent = message;
|
| 192 |
+
|
| 193 |
// Always reveal answers at the end of each round
|
| 194 |
this.revealAnswersInPlace(results.results);
|
| 195 |
+
|
| 196 |
// Show next button and hide submit button
|
| 197 |
this.elements.submitBtn.style.display = 'none';
|
| 198 |
this.elements.nextBtn.classList.remove('hidden');
|
|
|
|
| 220 |
try {
|
| 221 |
// Show loading immediately with specific message
|
| 222 |
this.showLoading(true, 'Loading passages...');
|
| 223 |
+
|
| 224 |
+
// Clear chat history when starting new round
|
| 225 |
this.chatUI.clearChatHistory();
|
| 226 |
+
|
| 227 |
// Always show loading for at least 1 second for smooth UX
|
| 228 |
const startTime = Date.now();
|
| 229 |
+
|
| 230 |
+
// Load next round
|
| 231 |
+
const roundData = await this.game.nextRound();
|
| 232 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
// Ensure loading is shown for at least half a second
|
| 234 |
const elapsedTime = Date.now() - startTime;
|
| 235 |
if (elapsedTime < 500) {
|
| 236 |
await new Promise(resolve => setTimeout(resolve, 500 - elapsedTime));
|
| 237 |
}
|
| 238 |
+
|
| 239 |
this.displayRound(roundData);
|
| 240 |
this.resetUI();
|
| 241 |
this.showLoading(false);
|
| 242 |
} catch (error) {
|
| 243 |
+
console.error('Error loading next round:', error);
|
| 244 |
+
this.showError('Could not load next round. Please try again.');
|
| 245 |
}
|
| 246 |
}
|
| 247 |
|
|
|
|
| 337 |
this.elements.loading.classList.remove('hidden');
|
| 338 |
this.elements.gameArea.classList.add('hidden');
|
| 339 |
}
|
| 340 |
+
|
| 341 |
+
checkForHighScore() {
|
| 342 |
+
// Check if current score qualifies for leaderboard
|
| 343 |
+
if (this.game.checkForHighScore()) {
|
| 344 |
+
const rank = this.game.getHighScoreRank();
|
| 345 |
+
const stats = this.game.leaderboardService.getStats();
|
| 346 |
+
const profile = this.game.leaderboardService.getPlayerProfile();
|
| 347 |
+
|
| 348 |
+
// If player hasn't entered initials yet, show initials entry
|
| 349 |
+
if (!profile.hasEnteredInitials) {
|
| 350 |
+
this.leaderboardUI.showInitialsEntry(
|
| 351 |
+
stats.highestLevel,
|
| 352 |
+
stats.roundAtHighestLevel,
|
| 353 |
+
rank,
|
| 354 |
+
(initials) => {
|
| 355 |
+
// Save to leaderboard
|
| 356 |
+
const finalRank = this.game.addToLeaderboard(initials);
|
| 357 |
+
console.log(`Added to leaderboard at rank ${finalRank}`);
|
| 358 |
+
}
|
| 359 |
+
);
|
| 360 |
+
} else {
|
| 361 |
+
// Update existing entry
|
| 362 |
+
const finalRank = this.game.addToLeaderboard(profile.initials);
|
| 363 |
+
console.log(`Updated leaderboard entry at rank ${finalRank}`);
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
+
}
|
| 367 |
}
|
| 368 |
|
| 369 |
// Initialize the app when DOM is loaded
|
src/clozeGameEngine.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
import bookDataService from './bookDataService.js';
|
| 3 |
import { AIService } from './aiService.js';
|
| 4 |
import ChatService from './conversationManager.js';
|
|
|
|
| 5 |
|
| 6 |
const aiService = new AIService();
|
| 7 |
|
|
@@ -19,16 +20,10 @@ class ClozeGame {
|
|
| 19 |
this.hints = [];
|
| 20 |
this.chatService = new ChatService(aiService);
|
| 21 |
this.lastResults = null; // Store results for answer revelation
|
| 22 |
-
this.
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
this.passages = []; // Array of two passages per round
|
| 27 |
-
this.currentPassageIndex = 0; // 0 for first passage, 1 for second
|
| 28 |
-
|
| 29 |
-
// Level progression tracking
|
| 30 |
-
this.passagesPassedAtCurrentLevel = 0; // Track successful passages at current level (not rounds)
|
| 31 |
-
console.log('🎮 GAME ENGINE INITIALIZED - Starting at Level 1, Passages passed: 0');
|
| 32 |
}
|
| 33 |
|
| 34 |
// --- User-visible framing helpers ---
|
|
@@ -42,16 +37,13 @@ class ClozeGame {
|
|
| 42 |
return {
|
| 43 |
round: this.currentRound,
|
| 44 |
level: this.currentLevel,
|
| 45 |
-
|
| 46 |
-
totalPassages: 2,
|
| 47 |
-
blanksPerPassage: this.getBlanksPerPassage(),
|
| 48 |
-
passagesPassedAtCurrentLevel: this.passagesPassedAtCurrentLevel
|
| 49 |
};
|
| 50 |
}
|
| 51 |
|
| 52 |
formatProgressText(snapshot = this.getProgressSnapshot()) {
|
| 53 |
const blanksLabel = `${snapshot.blanksPerPassage} blank${snapshot.blanksPerPassage > 1 ? 's' : ''}`;
|
| 54 |
-
return `Level ${snapshot.level} •
|
| 55 |
}
|
| 56 |
|
| 57 |
formatAdvancementText({ passed, correctCount, requiredCorrect, justAdvancedLevel }) {
|
|
@@ -59,11 +51,7 @@ class ClozeGame {
|
|
| 59 |
if (justAdvancedLevel) {
|
| 60 |
return `✓ Passed • Level up! Welcome to Level ${this.currentLevel}`;
|
| 61 |
}
|
| 62 |
-
|
| 63 |
-
if (needed === 0) {
|
| 64 |
-
return `✓ Passed`;
|
| 65 |
-
}
|
| 66 |
-
return `✓ Passed • ${needed} more passage${needed > 1 ? 's' : ''} to reach Level ${this.currentLevel + 1}`;
|
| 67 |
}
|
| 68 |
return `Try again • Need ${requiredCorrect}/${this.blanks.length} correct (you got ${correctCount})`;
|
| 69 |
}
|
|
@@ -80,54 +68,27 @@ class ClozeGame {
|
|
| 80 |
|
| 81 |
async startNewRound() {
|
| 82 |
try {
|
| 83 |
-
console.log(`🎲 STARTING NEW ROUND - Round: ${this.currentRound}, Level: ${this.currentLevel}
|
| 84 |
-
// Get
|
| 85 |
-
const
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
this.currentPassageIndex = 0;
|
| 96 |
-
|
| 97 |
-
// Calculate blanks per passage based on level
|
| 98 |
-
// Levels 1-5: 1 blank, 6-10: 2 blanks, 11+: 3 blanks
|
| 99 |
-
const blanksPerPassage = this.getBlanksPerPassage();
|
| 100 |
-
|
| 101 |
-
// Process both passages in a single API call
|
| 102 |
try {
|
| 103 |
-
const batchResult = await aiService.processBothPassages(
|
| 104 |
-
passage1, book1, passage2, book2, blanksPerPassage, this.currentLevel
|
| 105 |
-
);
|
| 106 |
-
|
| 107 |
-
// Store the preprocessed data for both passages
|
| 108 |
-
this.preprocessedData = batchResult;
|
| 109 |
-
|
| 110 |
-
// Debug: Log what the AI returned
|
| 111 |
-
console.log(`Level ${this.currentLevel}: Requested ${blanksPerPassage} blanks per passage`);
|
| 112 |
-
console.log(`Passage 1 received ${batchResult.passage1.words.length} words:`, batchResult.passage1.words);
|
| 113 |
-
console.log(`Passage 2 received ${batchResult.passage2.words.length} words:`, batchResult.passage2.words);
|
| 114 |
-
|
| 115 |
-
// Set up first passage using preprocessed data
|
| 116 |
-
this.currentBook = book1;
|
| 117 |
-
this.originalText = this.passages[0];
|
| 118 |
-
await this.createClozeTextFromPreprocessed(0);
|
| 119 |
-
await this.generateContextualization();
|
| 120 |
-
|
| 121 |
-
} catch (error) {
|
| 122 |
-
console.warn('Batch processing failed, falling back to sequential:', error);
|
| 123 |
-
// Fallback to sequential processing
|
| 124 |
-
this.currentBook = book1;
|
| 125 |
-
this.originalText = this.passages[0];
|
| 126 |
await this.createClozeText();
|
| 127 |
await new Promise(resolve => setTimeout(resolve, 1000));
|
| 128 |
await this.generateContextualization();
|
|
|
|
|
|
|
|
|
|
| 129 |
}
|
| 130 |
-
|
| 131 |
const snapshot = this.getProgressSnapshot();
|
| 132 |
return {
|
| 133 |
title: this.currentBook.title,
|
|
@@ -136,8 +97,6 @@ class ClozeGame {
|
|
| 136 |
blanks: this.blanks,
|
| 137 |
contextualization: this.contextualization,
|
| 138 |
hints: this.hints,
|
| 139 |
-
passageNumber: 1,
|
| 140 |
-
totalPassages: 2,
|
| 141 |
progressText: this.formatProgressText(snapshot)
|
| 142 |
};
|
| 143 |
} catch (error) {
|
|
@@ -351,159 +310,6 @@ class ClozeGame {
|
|
| 351 |
return passage.trim();
|
| 352 |
}
|
| 353 |
|
| 354 |
-
async createClozeTextFromPreprocessed(passageIndex) {
|
| 355 |
-
// Use preprocessed word selection from batch API call
|
| 356 |
-
const preprocessed = passageIndex === 0 ? this.preprocessedData.passage1 : this.preprocessedData.passage2;
|
| 357 |
-
let selectedWords = preprocessed.words;
|
| 358 |
-
|
| 359 |
-
// Calculate expected number of blanks based on level
|
| 360 |
-
let expectedBlanks;
|
| 361 |
-
if (this.currentLevel <= 5) {
|
| 362 |
-
expectedBlanks = 1;
|
| 363 |
-
} else if (this.currentLevel <= 10) {
|
| 364 |
-
expectedBlanks = 2;
|
| 365 |
-
} else {
|
| 366 |
-
expectedBlanks = 3;
|
| 367 |
-
}
|
| 368 |
-
|
| 369 |
-
// Only use fallback if AI provided no words at all
|
| 370 |
-
if (selectedWords.length === 0) {
|
| 371 |
-
console.warn(`AI provided no words, using manual fallback selection`);
|
| 372 |
-
const words = this.originalText.split(/\s+/);
|
| 373 |
-
const fallbackWords = this.selectWordsManually(words, expectedBlanks);
|
| 374 |
-
selectedWords = fallbackWords;
|
| 375 |
-
console.log(`Fallback words:`, selectedWords);
|
| 376 |
-
}
|
| 377 |
-
|
| 378 |
-
// Limit selected words to expected number
|
| 379 |
-
if (selectedWords.length > expectedBlanks) {
|
| 380 |
-
console.log(`AI returned ${selectedWords.length} words but expected ${expectedBlanks}, limiting to ${expectedBlanks}`);
|
| 381 |
-
selectedWords = selectedWords.slice(0, expectedBlanks);
|
| 382 |
-
}
|
| 383 |
-
|
| 384 |
-
// Split passage into words
|
| 385 |
-
const words = this.originalText.split(/(\s+)/);
|
| 386 |
-
const wordsOnly = words.filter(w => w.trim() !== '');
|
| 387 |
-
|
| 388 |
-
// Find indices of selected words using flexible matching
|
| 389 |
-
const selectedIndices = [];
|
| 390 |
-
selectedWords.forEach((word, wordIdx) => {
|
| 391 |
-
console.log(`Searching for word ${wordIdx + 1}/${selectedWords.length}: "${word}"`);
|
| 392 |
-
|
| 393 |
-
// First try exact match (cleaned)
|
| 394 |
-
let index = wordsOnly.findIndex((w, idx) => {
|
| 395 |
-
const cleanW = w.replace(/[^\w]/g, '').toLowerCase();
|
| 396 |
-
const cleanWord = word.replace(/[^\w]/g, '').toLowerCase();
|
| 397 |
-
return cleanW === cleanWord && !selectedIndices.includes(idx);
|
| 398 |
-
});
|
| 399 |
-
|
| 400 |
-
if (index !== -1) {
|
| 401 |
-
console.log(`✓ Found exact match: "${wordsOnly[index]}" at position ${index}`);
|
| 402 |
-
} else {
|
| 403 |
-
// Fallback to includes match if exact fails
|
| 404 |
-
index = wordsOnly.findIndex((w, idx) =>
|
| 405 |
-
w.toLowerCase().includes(word.toLowerCase()) && !selectedIndices.includes(idx)
|
| 406 |
-
);
|
| 407 |
-
|
| 408 |
-
if (index !== -1) {
|
| 409 |
-
console.log(`✓ Found includes match: "${wordsOnly[index]}" at position ${index}`);
|
| 410 |
-
} else {
|
| 411 |
-
// Enhanced fallback: try base word matching (remove common suffixes)
|
| 412 |
-
const baseWord = word.replace(/[^\w]/g, '').toLowerCase().replace(/(ed|ing|s|es|er|est)$/, '');
|
| 413 |
-
if (baseWord.length > 2) {
|
| 414 |
-
index = wordsOnly.findIndex((w, idx) => {
|
| 415 |
-
const cleanW = w.replace(/[^\w]/g, '').toLowerCase();
|
| 416 |
-
const baseW = cleanW.replace(/(ed|ing|s|es|er|est)$/, '');
|
| 417 |
-
return baseW === baseWord && !selectedIndices.includes(idx);
|
| 418 |
-
});
|
| 419 |
-
|
| 420 |
-
if (index !== -1) {
|
| 421 |
-
console.log(`✓ Found base word match: "${wordsOnly[index]}" at position ${index}`);
|
| 422 |
-
}
|
| 423 |
-
}
|
| 424 |
-
}
|
| 425 |
-
}
|
| 426 |
-
|
| 427 |
-
if (index !== -1) {
|
| 428 |
-
selectedIndices.push(index);
|
| 429 |
-
} else {
|
| 430 |
-
console.warn(`✗ Could not find word "${word}" in passage`);
|
| 431 |
-
}
|
| 432 |
-
});
|
| 433 |
-
|
| 434 |
-
// Ensure we have at least the expected number of blanks
|
| 435 |
-
if (selectedIndices.length < expectedBlanks) {
|
| 436 |
-
console.warn(`Only found ${selectedIndices.length} words, need ${expectedBlanks}. Using fallback selection.`);
|
| 437 |
-
const fallbackWords = this.selectWordsManually(wordsOnly, expectedBlanks - selectedIndices.length);
|
| 438 |
-
|
| 439 |
-
// Add fallback word indices
|
| 440 |
-
fallbackWords.forEach(fallbackWord => {
|
| 441 |
-
const cleanFallback = fallbackWord.toLowerCase().replace(/[^\w]/g, '');
|
| 442 |
-
const index = wordsOnly.findIndex((w, idx) => {
|
| 443 |
-
const cleanW = w.replace(/[^\w]/g, '').toLowerCase();
|
| 444 |
-
return cleanW === cleanFallback && !selectedIndices.includes(idx);
|
| 445 |
-
});
|
| 446 |
-
if (index !== -1) {
|
| 447 |
-
selectedIndices.push(index);
|
| 448 |
-
}
|
| 449 |
-
});
|
| 450 |
-
}
|
| 451 |
-
|
| 452 |
-
// Create blanks
|
| 453 |
-
this.blanks = [];
|
| 454 |
-
this.hints = [];
|
| 455 |
-
const clozeWords = [...wordsOnly];
|
| 456 |
-
|
| 457 |
-
console.log(`Creating ${selectedIndices.length} blanks from ${selectedWords.length} selected words`);
|
| 458 |
-
|
| 459 |
-
selectedIndices.forEach((wordIndex, blankIndex) => {
|
| 460 |
-
const originalWord = wordsOnly[wordIndex];
|
| 461 |
-
const cleanWord = originalWord.replace(/[^\w]/g, '');
|
| 462 |
-
|
| 463 |
-
this.blanks.push({
|
| 464 |
-
index: blankIndex,
|
| 465 |
-
originalWord: cleanWord,
|
| 466 |
-
wordIndex: wordIndex
|
| 467 |
-
});
|
| 468 |
-
|
| 469 |
-
// Initialize chat context for this word
|
| 470 |
-
const wordContext = {
|
| 471 |
-
originalWord: cleanWord,
|
| 472 |
-
sentence: this.originalText,
|
| 473 |
-
passage: this.originalText,
|
| 474 |
-
bookTitle: this.currentBook.title,
|
| 475 |
-
author: this.currentBook.author,
|
| 476 |
-
year: this.currentBook.year,
|
| 477 |
-
wordPosition: wordIndex,
|
| 478 |
-
difficulty: this.calculateWordDifficulty(cleanWord, wordIndex, wordsOnly)
|
| 479 |
-
};
|
| 480 |
-
|
| 481 |
-
this.chatService.initializeWordContext(`blank_${blankIndex}`, wordContext);
|
| 482 |
-
|
| 483 |
-
// Generate structural hint
|
| 484 |
-
const hint = this.currentLevel <= 2
|
| 485 |
-
? `${cleanWord.length} letters, starts with "${cleanWord[0]}", ends with "${cleanWord[cleanWord.length - 1]}"`
|
| 486 |
-
: `${cleanWord.length} letters, starts with "${cleanWord[0]}"`;
|
| 487 |
-
this.hints.push({ index: blankIndex, hint });
|
| 488 |
-
|
| 489 |
-
// Replace with placeholder
|
| 490 |
-
clozeWords[wordIndex] = `___BLANK_${blankIndex}___`;
|
| 491 |
-
});
|
| 492 |
-
|
| 493 |
-
// Reconstruct text with original spacing
|
| 494 |
-
let reconstructed = '';
|
| 495 |
-
let wordIndex = 0;
|
| 496 |
-
words.forEach(part => {
|
| 497 |
-
if (part.trim() === '') {
|
| 498 |
-
reconstructed += part;
|
| 499 |
-
} else {
|
| 500 |
-
reconstructed += clozeWords[wordIndex++];
|
| 501 |
-
}
|
| 502 |
-
});
|
| 503 |
-
|
| 504 |
-
this.clozeText = reconstructed;
|
| 505 |
-
this.userAnswers = new Array(this.blanks.length).fill('');
|
| 506 |
-
}
|
| 507 |
|
| 508 |
async createClozeText() {
|
| 509 |
const words = this.originalText.split(' ');
|
|
@@ -761,7 +567,8 @@ class ClozeGame {
|
|
| 761 |
try {
|
| 762 |
this.contextualization = await aiService.generateContextualization(
|
| 763 |
this.currentBook.title,
|
| 764 |
-
this.currentBook.author
|
|
|
|
| 765 |
);
|
| 766 |
return this.contextualization;
|
| 767 |
} catch (error) {
|
|
@@ -809,34 +616,30 @@ class ClozeGame {
|
|
| 809 |
|
| 810 |
const scorePercentage = Math.round((correctCount / this.blanks.length) * 100);
|
| 811 |
this.score = scorePercentage;
|
| 812 |
-
|
| 813 |
// Calculate pass requirements based on number of blanks
|
| 814 |
const totalBlanks = this.blanks.length;
|
| 815 |
const requiredCorrect = this.calculateRequiredCorrect(totalBlanks);
|
| 816 |
const passed = correctCount >= requiredCorrect;
|
| 817 |
-
|
| 818 |
-
// Track
|
|
|
|
|
|
|
|
|
|
| 819 |
if (passed) {
|
| 820 |
-
this.
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
// Advance level after 2 successful passages (not rounds)
|
| 824 |
-
if (this.passagesPassedAtCurrentLevel >= 2) {
|
| 825 |
-
const previousLevel = this.currentLevel;
|
| 826 |
-
this.currentLevel++;
|
| 827 |
-
this.passagesPassedAtCurrentLevel = 0; // Reset counter for new level
|
| 828 |
-
console.log(`🎉 LEVEL ADVANCEMENT: ${previousLevel} → ${this.currentLevel} (counter reset to 0)`);
|
| 829 |
-
} else {
|
| 830 |
-
console.log(`📊 Progress: Need ${2 - this.passagesPassedAtCurrentLevel} more passage(s) to advance from level ${this.currentLevel}`);
|
| 831 |
-
}
|
| 832 |
} else {
|
| 833 |
-
console.log(`❌
|
| 834 |
}
|
| 835 |
|
| 836 |
-
// Track if we just advanced levels
|
| 837 |
-
const justAdvancedLevel = passed && this.passagesPassedAtCurrentLevel === 0 && this.currentLevel > 1;
|
| 838 |
-
|
| 839 |
const snapshot = this.getProgressSnapshot();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 840 |
const resultsData = {
|
| 841 |
correct: correctCount,
|
| 842 |
total: this.blanks.length,
|
|
@@ -847,21 +650,18 @@ class ClozeGame {
|
|
| 847 |
shouldRevealAnswers: !passed,
|
| 848 |
requiredCorrect: requiredCorrect,
|
| 849 |
currentLevel: this.currentLevel,
|
| 850 |
-
passagesPassedAtCurrentLevel: this.passagesPassedAtCurrentLevel,
|
| 851 |
justAdvancedLevel: justAdvancedLevel,
|
| 852 |
round: snapshot.round,
|
| 853 |
-
|
| 854 |
-
totalPassages: snapshot.totalPassages,
|
| 855 |
progressText: this.formatProgressText(snapshot),
|
| 856 |
-
feedbackText: this.formatAdvancementText({ passed, correctCount, requiredCorrect, justAdvancedLevel })
|
| 857 |
-
nextActionText: snapshot.passageNumber === snapshot.totalPassages ? 'Next round' : 'Next passage'
|
| 858 |
};
|
| 859 |
|
|
|
|
|
|
|
|
|
|
| 860 |
// Store results for potential answer revelation
|
| 861 |
this.lastResults = resultsData;
|
| 862 |
-
|
| 863 |
-
// Store results for round-level tracking
|
| 864 |
-
this.roundResults[this.currentPassageIndex] = resultsData;
|
| 865 |
|
| 866 |
return resultsData;
|
| 867 |
}
|
|
@@ -887,68 +687,19 @@ class ClozeGame {
|
|
| 887 |
}));
|
| 888 |
}
|
| 889 |
|
| 890 |
-
async nextPassage() {
|
| 891 |
-
try {
|
| 892 |
-
// Move to the second passage in the current round
|
| 893 |
-
if (this.currentPassageIndex === 0 && this.passages && this.passages.length > 1) {
|
| 894 |
-
this.currentPassageIndex = 1;
|
| 895 |
-
console.log(`📖 MOVING TO PASSAGE 2/2 in Round ${this.currentRound} - Level: ${this.currentLevel}, Progress: ${this.passagesPassedAtCurrentLevel}/2`);
|
| 896 |
-
this.currentBook = this.currentBooks[1];
|
| 897 |
-
this.originalText = this.passages[1];
|
| 898 |
-
|
| 899 |
-
// Clear chat conversations for new passage
|
| 900 |
-
this.chatService.clearConversations();
|
| 901 |
-
|
| 902 |
-
// Clear last results (but keep roundResults for level advancement)
|
| 903 |
-
this.lastResults = null;
|
| 904 |
-
|
| 905 |
-
// Use preprocessed data if available
|
| 906 |
-
if (this.preprocessedData && this.preprocessedData.passage2) {
|
| 907 |
-
await this.createClozeTextFromPreprocessed(1);
|
| 908 |
-
await this.generateContextualization();
|
| 909 |
-
} else {
|
| 910 |
-
// Fallback to sequential processing
|
| 911 |
-
await this.createClozeText();
|
| 912 |
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
| 913 |
-
await this.generateContextualization();
|
| 914 |
-
}
|
| 915 |
-
|
| 916 |
-
const snapshot = this.getProgressSnapshot();
|
| 917 |
-
return {
|
| 918 |
-
title: this.currentBook.title,
|
| 919 |
-
author: this.currentBook.author,
|
| 920 |
-
text: this.clozeText,
|
| 921 |
-
blanks: this.blanks,
|
| 922 |
-
contextualization: this.contextualization,
|
| 923 |
-
hints: this.hints,
|
| 924 |
-
passageNumber: 2,
|
| 925 |
-
totalPassages: 2,
|
| 926 |
-
progressText: this.formatProgressText(snapshot)
|
| 927 |
-
};
|
| 928 |
-
} else {
|
| 929 |
-
// If we're already on the second passage, move to next round
|
| 930 |
-
return this.nextRound();
|
| 931 |
-
}
|
| 932 |
-
} catch (error) {
|
| 933 |
-
console.error('Error loading next passage:', error);
|
| 934 |
-
throw error;
|
| 935 |
-
}
|
| 936 |
-
}
|
| 937 |
-
|
| 938 |
nextRound() {
|
| 939 |
// Always increment round counter
|
| 940 |
this.currentRound++;
|
| 941 |
-
console.log(`🔄 NEW ROUND ${this.currentRound} - Level: ${this.currentLevel}
|
| 942 |
-
|
| 943 |
-
// Level advancement is now handled in submitAnswers() based on
|
| 944 |
-
|
| 945 |
// Clear chat conversations for new round
|
| 946 |
this.chatService.clearConversations();
|
| 947 |
-
|
| 948 |
// Clear results since we're moving to new round
|
| 949 |
this.lastResults = null;
|
| 950 |
-
|
| 951 |
-
|
| 952 |
return this.startNewRound();
|
| 953 |
}
|
| 954 |
|
|
@@ -1017,30 +768,63 @@ class ClozeGame {
|
|
| 1017 |
// Enhanced render method to include chat buttons
|
| 1018 |
renderClozeTextWithChat() {
|
| 1019 |
let html = this.clozeText;
|
| 1020 |
-
|
| 1021 |
this.blanks.forEach((blank, index) => {
|
| 1022 |
const chatButtonId = `chat-btn-${index}`;
|
| 1023 |
const inputHtml = `
|
| 1024 |
<span class="inline-flex items-center">
|
| 1025 |
-
<input type="text"
|
| 1026 |
-
class="cloze-input"
|
| 1027 |
-
data-blank-index="${index}"
|
| 1028 |
placeholder="${'_'.repeat(Math.max(3, blank.originalWord.length))}"
|
| 1029 |
style="width: ${Math.max(50, blank.originalWord.length * 10)}px;">
|
| 1030 |
-
<button id="${chatButtonId}"
|
| 1031 |
-
class="chat-button text-blue-500 hover:text-blue-700"
|
| 1032 |
data-blank-index="${index}"
|
| 1033 |
title="Ask question about this word"
|
| 1034 |
style="font-size: 1.5rem; line-height: 1;">
|
| 1035 |
💬
|
| 1036 |
</button>
|
| 1037 |
</span>`;
|
| 1038 |
-
|
| 1039 |
html = html.replace(`___BLANK_${index}___`, inputHtml);
|
| 1040 |
});
|
| 1041 |
|
| 1042 |
return html;
|
| 1043 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1044 |
}
|
| 1045 |
|
| 1046 |
export default ClozeGame;
|
|
|
|
| 2 |
import bookDataService from './bookDataService.js';
|
| 3 |
import { AIService } from './aiService.js';
|
| 4 |
import ChatService from './conversationManager.js';
|
| 5 |
+
import { LeaderboardService } from './leaderboardService.js';
|
| 6 |
|
| 7 |
const aiService = new AIService();
|
| 8 |
|
|
|
|
| 20 |
this.hints = [];
|
| 21 |
this.chatService = new ChatService(aiService);
|
| 22 |
this.lastResults = null; // Store results for answer revelation
|
| 23 |
+
this.leaderboardService = new LeaderboardService();
|
| 24 |
+
this.passagesPassedAtCurrentLevel = 0; // Track progress toward level advancement
|
| 25 |
+
|
| 26 |
+
console.log('🎮 GAME ENGINE INITIALIZED - Starting at Level 1, Round 1');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
}
|
| 28 |
|
| 29 |
// --- User-visible framing helpers ---
|
|
|
|
| 37 |
return {
|
| 38 |
round: this.currentRound,
|
| 39 |
level: this.currentLevel,
|
| 40 |
+
blanksPerPassage: this.getBlanksPerPassage()
|
|
|
|
|
|
|
|
|
|
| 41 |
};
|
| 42 |
}
|
| 43 |
|
| 44 |
formatProgressText(snapshot = this.getProgressSnapshot()) {
|
| 45 |
const blanksLabel = `${snapshot.blanksPerPassage} blank${snapshot.blanksPerPassage > 1 ? 's' : ''}`;
|
| 46 |
+
return `Level ${snapshot.level} • ${blanksLabel}`;
|
| 47 |
}
|
| 48 |
|
| 49 |
formatAdvancementText({ passed, correctCount, requiredCorrect, justAdvancedLevel }) {
|
|
|
|
| 51 |
if (justAdvancedLevel) {
|
| 52 |
return `✓ Passed • Level up! Welcome to Level ${this.currentLevel}`;
|
| 53 |
}
|
| 54 |
+
return `✓ Passed • Advancing to next level!`;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
return `Try again • Need ${requiredCorrect}/${this.blanks.length} correct (you got ${correctCount})`;
|
| 57 |
}
|
|
|
|
| 68 |
|
| 69 |
async startNewRound() {
|
| 70 |
try {
|
| 71 |
+
console.log(`🎲 STARTING NEW ROUND - Round: ${this.currentRound}, Level: ${this.currentLevel}`);
|
| 72 |
+
// Get one book for this round based on current level criteria
|
| 73 |
+
const book = await bookDataService.getBookByLevelCriteria(this.currentLevel);
|
| 74 |
+
|
| 75 |
+
// Extract passage from book
|
| 76 |
+
const passage = this.extractCoherentPassage(book.text);
|
| 77 |
+
|
| 78 |
+
// Store book and passage
|
| 79 |
+
this.currentBook = book;
|
| 80 |
+
this.originalText = passage.trim();
|
| 81 |
+
|
| 82 |
+
// Create cloze text using AI
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
await this.createClozeText();
|
| 85 |
await new Promise(resolve => setTimeout(resolve, 1000));
|
| 86 |
await this.generateContextualization();
|
| 87 |
+
} catch (error) {
|
| 88 |
+
console.warn('AI processing failed:', error);
|
| 89 |
+
throw error;
|
| 90 |
}
|
| 91 |
+
|
| 92 |
const snapshot = this.getProgressSnapshot();
|
| 93 |
return {
|
| 94 |
title: this.currentBook.title,
|
|
|
|
| 97 |
blanks: this.blanks,
|
| 98 |
contextualization: this.contextualization,
|
| 99 |
hints: this.hints,
|
|
|
|
|
|
|
| 100 |
progressText: this.formatProgressText(snapshot)
|
| 101 |
};
|
| 102 |
} catch (error) {
|
|
|
|
| 310 |
return passage.trim();
|
| 311 |
}
|
| 312 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
|
| 314 |
async createClozeText() {
|
| 315 |
const words = this.originalText.split(' ');
|
|
|
|
| 567 |
try {
|
| 568 |
this.contextualization = await aiService.generateContextualization(
|
| 569 |
this.currentBook.title,
|
| 570 |
+
this.currentBook.author,
|
| 571 |
+
this.originalText
|
| 572 |
);
|
| 573 |
return this.contextualization;
|
| 574 |
} catch (error) {
|
|
|
|
| 616 |
|
| 617 |
const scorePercentage = Math.round((correctCount / this.blanks.length) * 100);
|
| 618 |
this.score = scorePercentage;
|
| 619 |
+
|
| 620 |
// Calculate pass requirements based on number of blanks
|
| 621 |
const totalBlanks = this.blanks.length;
|
| 622 |
const requiredCorrect = this.calculateRequiredCorrect(totalBlanks);
|
| 623 |
const passed = correctCount >= requiredCorrect;
|
| 624 |
+
|
| 625 |
+
// Track if we're advancing level
|
| 626 |
+
const justAdvancedLevel = passed;
|
| 627 |
+
|
| 628 |
+
// Advance level on pass
|
| 629 |
if (passed) {
|
| 630 |
+
const previousLevel = this.currentLevel;
|
| 631 |
+
this.currentLevel++;
|
| 632 |
+
console.log(`✅ ROUND PASSED - Level advancement: ${previousLevel} → ${this.currentLevel}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
} else {
|
| 634 |
+
console.log(`❌ ROUND FAILED - Staying at Level: ${this.currentLevel}`);
|
| 635 |
}
|
| 636 |
|
|
|
|
|
|
|
|
|
|
| 637 |
const snapshot = this.getProgressSnapshot();
|
| 638 |
+
|
| 639 |
+
// Update passage tracking
|
| 640 |
+
const stats = this.leaderboardService.getStats();
|
| 641 |
+
const totalPassagesPassed = passed ? (stats.totalPassagesPassed + 1) : stats.totalPassagesPassed;
|
| 642 |
+
|
| 643 |
const resultsData = {
|
| 644 |
correct: correctCount,
|
| 645 |
total: this.blanks.length,
|
|
|
|
| 650 |
shouldRevealAnswers: !passed,
|
| 651 |
requiredCorrect: requiredCorrect,
|
| 652 |
currentLevel: this.currentLevel,
|
|
|
|
| 653 |
justAdvancedLevel: justAdvancedLevel,
|
| 654 |
round: snapshot.round,
|
| 655 |
+
passagesPassed: totalPassagesPassed,
|
|
|
|
| 656 |
progressText: this.formatProgressText(snapshot),
|
| 657 |
+
feedbackText: this.formatAdvancementText({ passed, correctCount, requiredCorrect, justAdvancedLevel })
|
|
|
|
| 658 |
};
|
| 659 |
|
| 660 |
+
// Update leaderboard stats
|
| 661 |
+
this.leaderboardService.updateStats(resultsData);
|
| 662 |
+
|
| 663 |
// Store results for potential answer revelation
|
| 664 |
this.lastResults = resultsData;
|
|
|
|
|
|
|
|
|
|
| 665 |
|
| 666 |
return resultsData;
|
| 667 |
}
|
|
|
|
| 687 |
}));
|
| 688 |
}
|
| 689 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 690 |
nextRound() {
|
| 691 |
// Always increment round counter
|
| 692 |
this.currentRound++;
|
| 693 |
+
console.log(`🔄 NEW ROUND ${this.currentRound} - Level: ${this.currentLevel}`);
|
| 694 |
+
|
| 695 |
+
// Level advancement is now handled in submitAnswers() based on pass/fail
|
| 696 |
+
|
| 697 |
// Clear chat conversations for new round
|
| 698 |
this.chatService.clearConversations();
|
| 699 |
+
|
| 700 |
// Clear results since we're moving to new round
|
| 701 |
this.lastResults = null;
|
| 702 |
+
|
|
|
|
| 703 |
return this.startNewRound();
|
| 704 |
}
|
| 705 |
|
|
|
|
| 768 |
// Enhanced render method to include chat buttons
|
| 769 |
renderClozeTextWithChat() {
|
| 770 |
let html = this.clozeText;
|
| 771 |
+
|
| 772 |
this.blanks.forEach((blank, index) => {
|
| 773 |
const chatButtonId = `chat-btn-${index}`;
|
| 774 |
const inputHtml = `
|
| 775 |
<span class="inline-flex items-center">
|
| 776 |
+
<input type="text"
|
| 777 |
+
class="cloze-input"
|
| 778 |
+
data-blank-index="${index}"
|
| 779 |
placeholder="${'_'.repeat(Math.max(3, blank.originalWord.length))}"
|
| 780 |
style="width: ${Math.max(50, blank.originalWord.length * 10)}px;">
|
| 781 |
+
<button id="${chatButtonId}"
|
| 782 |
+
class="chat-button text-blue-500 hover:text-blue-700"
|
| 783 |
data-blank-index="${index}"
|
| 784 |
title="Ask question about this word"
|
| 785 |
style="font-size: 1.5rem; line-height: 1;">
|
| 786 |
💬
|
| 787 |
</button>
|
| 788 |
</span>`;
|
| 789 |
+
|
| 790 |
html = html.replace(`___BLANK_${index}___`, inputHtml);
|
| 791 |
});
|
| 792 |
|
| 793 |
return html;
|
| 794 |
}
|
| 795 |
+
|
| 796 |
+
// Leaderboard integration methods
|
| 797 |
+
checkForHighScore() {
|
| 798 |
+
const stats = this.leaderboardService.getStats();
|
| 799 |
+
return this.leaderboardService.qualifiesForLeaderboard(
|
| 800 |
+
stats.highestLevel,
|
| 801 |
+
stats.roundAtHighestLevel,
|
| 802 |
+
stats.totalPassagesPassed
|
| 803 |
+
);
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
getHighScoreRank() {
|
| 807 |
+
const stats = this.leaderboardService.getStats();
|
| 808 |
+
return this.leaderboardService.getRankForScore(
|
| 809 |
+
stats.highestLevel,
|
| 810 |
+
stats.roundAtHighestLevel,
|
| 811 |
+
stats.totalPassagesPassed
|
| 812 |
+
);
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
addToLeaderboard(initials) {
|
| 816 |
+
const stats = this.leaderboardService.getStats();
|
| 817 |
+
return this.leaderboardService.addEntry(
|
| 818 |
+
initials,
|
| 819 |
+
stats.highestLevel,
|
| 820 |
+
stats.roundAtHighestLevel,
|
| 821 |
+
stats.totalPassagesPassed
|
| 822 |
+
);
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
+
getLeaderboardStats() {
|
| 826 |
+
return this.leaderboardService.getPlayerStats();
|
| 827 |
+
}
|
| 828 |
}
|
| 829 |
|
| 830 |
export default ClozeGame;
|
src/leaderboardService.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Leaderboard Service
|
| 3 |
+
* Manages high scores, player stats, and localStorage persistence
|
| 4 |
+
* Following arcade conventions with 3-letter initials and top 10 tracking
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
export class LeaderboardService {
|
| 8 |
+
constructor() {
|
| 9 |
+
this.storageKeys = {
|
| 10 |
+
leaderboard: 'cloze-reader-leaderboard',
|
| 11 |
+
player: 'cloze-reader-player',
|
| 12 |
+
stats: 'cloze-reader-stats'
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
this.maxEntries = 10;
|
| 16 |
+
this.initializeStorage();
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Initialize localStorage with default values if needed
|
| 21 |
+
*/
|
| 22 |
+
initializeStorage() {
|
| 23 |
+
if (!this.getLeaderboard()) {
|
| 24 |
+
this.saveLeaderboard([]);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
if (!this.getPlayerProfile()) {
|
| 28 |
+
this.savePlayerProfile({
|
| 29 |
+
initials: null,
|
| 30 |
+
hasEnteredInitials: false,
|
| 31 |
+
gamesPlayed: 0,
|
| 32 |
+
lastPlayed: null
|
| 33 |
+
});
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
if (!this.getStats()) {
|
| 37 |
+
this.saveStats(this.createEmptyStats());
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* Create empty stats object
|
| 43 |
+
*/
|
| 44 |
+
createEmptyStats() {
|
| 45 |
+
return {
|
| 46 |
+
highestLevel: 1,
|
| 47 |
+
roundAtHighestLevel: 1,
|
| 48 |
+
totalPassagesPassed: 0,
|
| 49 |
+
totalPassagesAttempted: 0,
|
| 50 |
+
longestStreak: 0,
|
| 51 |
+
currentStreak: 0,
|
| 52 |
+
perfectRounds: 0,
|
| 53 |
+
totalCorrectWords: 0,
|
| 54 |
+
uniqueWordsCorrect: new Set(),
|
| 55 |
+
gamesPlayed: 0,
|
| 56 |
+
lastPlayed: null
|
| 57 |
+
};
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/**
|
| 61 |
+
* Get leaderboard from localStorage
|
| 62 |
+
*/
|
| 63 |
+
getLeaderboard() {
|
| 64 |
+
try {
|
| 65 |
+
const data = localStorage.getItem(this.storageKeys.leaderboard);
|
| 66 |
+
return data ? JSON.parse(data) : null;
|
| 67 |
+
} catch (e) {
|
| 68 |
+
console.error('Error reading leaderboard:', e);
|
| 69 |
+
return [];
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/**
|
| 74 |
+
* Save leaderboard to localStorage
|
| 75 |
+
*/
|
| 76 |
+
saveLeaderboard(entries) {
|
| 77 |
+
try {
|
| 78 |
+
localStorage.setItem(this.storageKeys.leaderboard, JSON.stringify(entries));
|
| 79 |
+
} catch (e) {
|
| 80 |
+
console.error('Error saving leaderboard:', e);
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* Get player profile from localStorage
|
| 86 |
+
*/
|
| 87 |
+
getPlayerProfile() {
|
| 88 |
+
try {
|
| 89 |
+
const data = localStorage.getItem(this.storageKeys.player);
|
| 90 |
+
return data ? JSON.parse(data) : null;
|
| 91 |
+
} catch (e) {
|
| 92 |
+
console.error('Error reading player profile:', e);
|
| 93 |
+
return null;
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
/**
|
| 98 |
+
* Save player profile to localStorage
|
| 99 |
+
*/
|
| 100 |
+
savePlayerProfile(profile) {
|
| 101 |
+
try {
|
| 102 |
+
localStorage.setItem(this.storageKeys.player, JSON.stringify(profile));
|
| 103 |
+
} catch (e) {
|
| 104 |
+
console.error('Error saving player profile:', e);
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/**
|
| 109 |
+
* Get stats from localStorage
|
| 110 |
+
*/
|
| 111 |
+
getStats() {
|
| 112 |
+
try {
|
| 113 |
+
const data = localStorage.getItem(this.storageKeys.stats);
|
| 114 |
+
if (!data) return null;
|
| 115 |
+
|
| 116 |
+
const stats = JSON.parse(data);
|
| 117 |
+
// Convert uniqueWordsCorrect back to Set
|
| 118 |
+
if (stats.uniqueWordsCorrect && Array.isArray(stats.uniqueWordsCorrect)) {
|
| 119 |
+
stats.uniqueWordsCorrect = new Set(stats.uniqueWordsCorrect);
|
| 120 |
+
} else {
|
| 121 |
+
stats.uniqueWordsCorrect = new Set();
|
| 122 |
+
}
|
| 123 |
+
return stats;
|
| 124 |
+
} catch (e) {
|
| 125 |
+
console.error('Error reading stats:', e);
|
| 126 |
+
return null;
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
/**
|
| 131 |
+
* Save stats to localStorage
|
| 132 |
+
*/
|
| 133 |
+
saveStats(stats) {
|
| 134 |
+
try {
|
| 135 |
+
// Convert Set to Array for JSON serialization
|
| 136 |
+
const statsToSave = {
|
| 137 |
+
...stats,
|
| 138 |
+
uniqueWordsCorrect: Array.from(stats.uniqueWordsCorrect || [])
|
| 139 |
+
};
|
| 140 |
+
localStorage.setItem(this.storageKeys.stats, JSON.stringify(statsToSave));
|
| 141 |
+
} catch (e) {
|
| 142 |
+
console.error('Error saving stats:', e);
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/**
|
| 147 |
+
* Validate and sanitize initials (3 letters, A-Z only)
|
| 148 |
+
*/
|
| 149 |
+
validateInitials(initials) {
|
| 150 |
+
if (!initials || typeof initials !== 'string') {
|
| 151 |
+
return false;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
const sanitized = initials.toUpperCase().replace(/[^A-Z]/g, '');
|
| 155 |
+
return sanitized.length === 3 ? sanitized : false;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/**
|
| 159 |
+
* Sort leaderboard entries
|
| 160 |
+
* Primary: Level (desc), Secondary: Round (desc), Tertiary: Passages passed (desc)
|
| 161 |
+
*/
|
| 162 |
+
sortLeaderboard(entries) {
|
| 163 |
+
return entries.sort((a, b) => {
|
| 164 |
+
// Primary: Level (higher is better)
|
| 165 |
+
if (b.level !== a.level) {
|
| 166 |
+
return b.level - a.level;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// Secondary: Round at that level (higher is better)
|
| 170 |
+
if (b.round !== a.round) {
|
| 171 |
+
return b.round - a.round;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// Tertiary: Total passages passed (higher is better)
|
| 175 |
+
if (b.passagesPassed !== a.passagesPassed) {
|
| 176 |
+
return b.passagesPassed - a.passagesPassed;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// Quaternary: Date (newer is better)
|
| 180 |
+
return new Date(b.date) - new Date(a.date);
|
| 181 |
+
});
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
/**
|
| 185 |
+
* Check if a score qualifies for the leaderboard
|
| 186 |
+
*/
|
| 187 |
+
qualifiesForLeaderboard(level, round, passagesPassed) {
|
| 188 |
+
const leaderboard = this.getLeaderboard();
|
| 189 |
+
|
| 190 |
+
// If leaderboard isn't full, always qualifies
|
| 191 |
+
if (leaderboard.length < this.maxEntries) {
|
| 192 |
+
return true;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
// Check if better than lowest entry
|
| 196 |
+
const lowestEntry = leaderboard[leaderboard.length - 1];
|
| 197 |
+
|
| 198 |
+
if (level > lowestEntry.level) return true;
|
| 199 |
+
if (level === lowestEntry.level && round > lowestEntry.round) return true;
|
| 200 |
+
if (level === lowestEntry.level && round === lowestEntry.round && passagesPassed > lowestEntry.passagesPassed) return true;
|
| 201 |
+
|
| 202 |
+
return false;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
/**
|
| 206 |
+
* Get the rank position for a score (1-10, or null if doesn't qualify)
|
| 207 |
+
*/
|
| 208 |
+
getRankForScore(level, round, passagesPassed) {
|
| 209 |
+
if (!this.qualifiesForLeaderboard(level, round, passagesPassed)) {
|
| 210 |
+
return null;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
const leaderboard = this.getLeaderboard();
|
| 214 |
+
const tempEntry = { level, round, passagesPassed, date: new Date().toISOString() };
|
| 215 |
+
const tempLeaderboard = [...leaderboard, tempEntry];
|
| 216 |
+
const sorted = this.sortLeaderboard(tempLeaderboard);
|
| 217 |
+
|
| 218 |
+
return sorted.findIndex(entry => entry === tempEntry) + 1;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
/**
|
| 222 |
+
* Add a new entry to the leaderboard
|
| 223 |
+
*/
|
| 224 |
+
addEntry(initials, level, round, passagesPassed) {
|
| 225 |
+
const validInitials = this.validateInitials(initials);
|
| 226 |
+
if (!validInitials) {
|
| 227 |
+
console.error('Invalid initials:', initials);
|
| 228 |
+
return false;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
const leaderboard = this.getLeaderboard();
|
| 232 |
+
const newEntry = {
|
| 233 |
+
initials: validInitials,
|
| 234 |
+
level,
|
| 235 |
+
round,
|
| 236 |
+
passagesPassed,
|
| 237 |
+
date: new Date().toISOString()
|
| 238 |
+
};
|
| 239 |
+
|
| 240 |
+
leaderboard.push(newEntry);
|
| 241 |
+
const sorted = this.sortLeaderboard(leaderboard);
|
| 242 |
+
|
| 243 |
+
// Keep only top 10
|
| 244 |
+
const trimmed = sorted.slice(0, this.maxEntries);
|
| 245 |
+
this.saveLeaderboard(trimmed);
|
| 246 |
+
|
| 247 |
+
return sorted.findIndex(entry => entry === newEntry) + 1; // Return rank
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/**
|
| 251 |
+
* Update session stats after a passage attempt
|
| 252 |
+
*/
|
| 253 |
+
updateStats(data) {
|
| 254 |
+
const stats = this.getStats() || this.createEmptyStats();
|
| 255 |
+
|
| 256 |
+
stats.totalPassagesAttempted++;
|
| 257 |
+
|
| 258 |
+
if (data.passed) {
|
| 259 |
+
stats.totalPassagesPassed++;
|
| 260 |
+
stats.currentStreak++;
|
| 261 |
+
stats.longestStreak = Math.max(stats.longestStreak, stats.currentStreak);
|
| 262 |
+
} else {
|
| 263 |
+
stats.currentStreak = 0;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
// Track highest level reached
|
| 267 |
+
if (data.currentLevel > stats.highestLevel) {
|
| 268 |
+
stats.highestLevel = data.currentLevel;
|
| 269 |
+
stats.roundAtHighestLevel = data.round;
|
| 270 |
+
} else if (data.currentLevel === stats.highestLevel) {
|
| 271 |
+
stats.roundAtHighestLevel = Math.max(stats.roundAtHighestLevel, data.round);
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// Track correct words
|
| 275 |
+
if (data.results) {
|
| 276 |
+
data.results.forEach(result => {
|
| 277 |
+
if (result.isCorrect) {
|
| 278 |
+
stats.totalCorrectWords++;
|
| 279 |
+
const word = result.correctAnswer.toLowerCase();
|
| 280 |
+
stats.uniqueWordsCorrect.add(word);
|
| 281 |
+
}
|
| 282 |
+
});
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
// Track perfect rounds (if both passages in round were 100%)
|
| 286 |
+
if (data.percentage === 100 && data.passagesPassed % 2 === 0) {
|
| 287 |
+
stats.perfectRounds++;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
stats.lastPlayed = new Date().toISOString();
|
| 291 |
+
|
| 292 |
+
this.saveStats(stats);
|
| 293 |
+
return stats;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
/**
|
| 297 |
+
* Get formatted leaderboard for display
|
| 298 |
+
*/
|
| 299 |
+
getFormattedLeaderboard() {
|
| 300 |
+
const leaderboard = this.getLeaderboard();
|
| 301 |
+
const player = this.getPlayerProfile();
|
| 302 |
+
const stats = this.getStats();
|
| 303 |
+
|
| 304 |
+
return {
|
| 305 |
+
entries: leaderboard.map((entry, index) => ({
|
| 306 |
+
rank: index + 1,
|
| 307 |
+
initials: entry.initials,
|
| 308 |
+
level: entry.level,
|
| 309 |
+
round: entry.round,
|
| 310 |
+
passagesPassed: entry.passagesPassed,
|
| 311 |
+
date: entry.date,
|
| 312 |
+
isPlayer: player && player.initials === entry.initials
|
| 313 |
+
})),
|
| 314 |
+
playerBest: stats ? {
|
| 315 |
+
level: stats.highestLevel,
|
| 316 |
+
round: stats.roundAtHighestLevel,
|
| 317 |
+
passagesPassed: stats.totalPassagesPassed
|
| 318 |
+
} : null,
|
| 319 |
+
playerInitials: player ? player.initials : null
|
| 320 |
+
};
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
/**
|
| 324 |
+
* Reset all leaderboard data (for testing or user request)
|
| 325 |
+
*/
|
| 326 |
+
resetAll() {
|
| 327 |
+
this.saveLeaderboard([]);
|
| 328 |
+
this.savePlayerProfile({
|
| 329 |
+
initials: null,
|
| 330 |
+
hasEnteredInitials: false,
|
| 331 |
+
gamesPlayed: 0,
|
| 332 |
+
lastPlayed: null
|
| 333 |
+
});
|
| 334 |
+
this.saveStats(this.createEmptyStats());
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
/**
|
| 338 |
+
* Get player stats summary
|
| 339 |
+
*/
|
| 340 |
+
getPlayerStats() {
|
| 341 |
+
const stats = this.getStats() || this.createEmptyStats();
|
| 342 |
+
const profile = this.getPlayerProfile();
|
| 343 |
+
|
| 344 |
+
return {
|
| 345 |
+
initials: profile?.initials || '---',
|
| 346 |
+
highestLevel: stats.highestLevel,
|
| 347 |
+
roundAtHighestLevel: stats.roundAtHighestLevel,
|
| 348 |
+
totalPassagesPassed: stats.totalPassagesPassed,
|
| 349 |
+
totalPassagesAttempted: stats.totalPassagesAttempted,
|
| 350 |
+
successRate: stats.totalPassagesAttempted > 0
|
| 351 |
+
? Math.round((stats.totalPassagesPassed / stats.totalPassagesAttempted) * 100)
|
| 352 |
+
: 0,
|
| 353 |
+
longestStreak: stats.longestStreak,
|
| 354 |
+
currentStreak: stats.currentStreak,
|
| 355 |
+
perfectRounds: stats.perfectRounds,
|
| 356 |
+
totalCorrectWords: stats.totalCorrectWords,
|
| 357 |
+
uniqueWords: stats.uniqueWordsCorrect.size,
|
| 358 |
+
gamesPlayed: stats.gamesPlayed,
|
| 359 |
+
lastPlayed: stats.lastPlayed
|
| 360 |
+
};
|
| 361 |
+
}
|
| 362 |
+
}
|
src/leaderboardUI.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Leaderboard UI
|
| 3 |
+
* Modal display and initials entry interface
|
| 4 |
+
* Following arcade conventions with vintage aesthetic
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
export class LeaderboardUI {
|
| 8 |
+
constructor(leaderboardService) {
|
| 9 |
+
this.service = leaderboardService;
|
| 10 |
+
this.modal = null;
|
| 11 |
+
this.initialsModal = null;
|
| 12 |
+
this.currentSlot = 0;
|
| 13 |
+
this.initials = ['A', 'A', 'A'];
|
| 14 |
+
this.onInitialsSubmit = null;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* Show the leaderboard modal
|
| 19 |
+
*/
|
| 20 |
+
show() {
|
| 21 |
+
// Remove existing modal if any
|
| 22 |
+
this.hide();
|
| 23 |
+
|
| 24 |
+
const data = this.service.getFormattedLeaderboard();
|
| 25 |
+
const playerStats = this.service.getPlayerStats();
|
| 26 |
+
|
| 27 |
+
// Create modal HTML
|
| 28 |
+
this.modal = document.createElement('div');
|
| 29 |
+
this.modal.className = 'leaderboard-overlay';
|
| 30 |
+
this.modal.innerHTML = `
|
| 31 |
+
<div class="leaderboard-modal">
|
| 32 |
+
<div class="leaderboard-header">
|
| 33 |
+
<h2 class="leaderboard-title">🏆 HIGH SCORES 🏆</h2>
|
| 34 |
+
<button class="leaderboard-close" aria-label="Close leaderboard">×</button>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<div class="leaderboard-content">
|
| 38 |
+
<div class="leaderboard-list">
|
| 39 |
+
${this.generateLeaderboardHTML(data.entries, data.playerInitials)}
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
${playerStats.highestLevel > 1 ? `
|
| 43 |
+
<div class="leaderboard-player-stats">
|
| 44 |
+
<div class="player-best">
|
| 45 |
+
Your Best: <span class="highlight">Level ${playerStats.highestLevel} (Round ${playerStats.roundAtHighestLevel})</span>
|
| 46 |
+
</div>
|
| 47 |
+
<div class="player-stats-details">
|
| 48 |
+
<div>Passages: ${playerStats.totalPassagesPassed}/${playerStats.totalPassagesAttempted} (${playerStats.successRate}%)</div>
|
| 49 |
+
<div>Longest Streak: ${playerStats.longestStreak} • Perfect Rounds: ${playerStats.perfectRounds}</div>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
` : ''}
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
`;
|
| 56 |
+
|
| 57 |
+
document.body.appendChild(this.modal);
|
| 58 |
+
|
| 59 |
+
// Animate in
|
| 60 |
+
requestAnimationFrame(() => {
|
| 61 |
+
this.modal.classList.add('visible');
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
// Add event listeners
|
| 65 |
+
this.modal.querySelector('.leaderboard-close').addEventListener('click', () => this.hide());
|
| 66 |
+
this.modal.addEventListener('click', (e) => {
|
| 67 |
+
if (e.target === this.modal) {
|
| 68 |
+
this.hide();
|
| 69 |
+
}
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
// ESC key to close
|
| 73 |
+
this.escHandler = (e) => {
|
| 74 |
+
if (e.key === 'Escape') {
|
| 75 |
+
this.hide();
|
| 76 |
+
}
|
| 77 |
+
};
|
| 78 |
+
document.addEventListener('keydown', this.escHandler);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/**
|
| 82 |
+
* Generate HTML for leaderboard entries
|
| 83 |
+
*/
|
| 84 |
+
generateLeaderboardHTML(entries, playerInitials) {
|
| 85 |
+
if (entries.length === 0) {
|
| 86 |
+
return `
|
| 87 |
+
<div class="leaderboard-empty">
|
| 88 |
+
<p>No high scores yet!</p>
|
| 89 |
+
<p class="text-sm">Be the first to reach Level 2!</p>
|
| 90 |
+
</div>
|
| 91 |
+
`;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
return entries.map(entry => {
|
| 95 |
+
const rankClass = this.getRankClass(entry.rank);
|
| 96 |
+
const isPlayer = entry.initials === playerInitials;
|
| 97 |
+
const playerClass = isPlayer ? 'player-entry' : '';
|
| 98 |
+
|
| 99 |
+
return `
|
| 100 |
+
<div class="leaderboard-entry ${rankClass} ${playerClass}">
|
| 101 |
+
<span class="entry-rank">#${entry.rank}</span>
|
| 102 |
+
<span class="entry-initials">${entry.initials}</span>
|
| 103 |
+
<span class="entry-score">Level ${entry.level} <span class="entry-round">(Round ${entry.round})</span></span>
|
| 104 |
+
</div>
|
| 105 |
+
`;
|
| 106 |
+
}).join('');
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/**
|
| 110 |
+
* Get CSS class for rank-based styling
|
| 111 |
+
*/
|
| 112 |
+
getRankClass(rank) {
|
| 113 |
+
if (rank === 1) return 'rank-gold';
|
| 114 |
+
if (rank === 2 || rank === 3) return 'rank-silver';
|
| 115 |
+
return 'rank-standard';
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/**
|
| 119 |
+
* Hide the leaderboard modal
|
| 120 |
+
*/
|
| 121 |
+
hide() {
|
| 122 |
+
if (this.modal) {
|
| 123 |
+
this.modal.classList.remove('visible');
|
| 124 |
+
setTimeout(() => {
|
| 125 |
+
if (this.modal && this.modal.parentNode) {
|
| 126 |
+
this.modal.parentNode.removeChild(this.modal);
|
| 127 |
+
}
|
| 128 |
+
this.modal = null;
|
| 129 |
+
}, 300);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
if (this.escHandler) {
|
| 133 |
+
document.removeEventListener('keydown', this.escHandler);
|
| 134 |
+
this.escHandler = null;
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/**
|
| 139 |
+
* Show initials entry screen for new high score
|
| 140 |
+
*/
|
| 141 |
+
showInitialsEntry(level, round, rank, onSubmit) {
|
| 142 |
+
// Store callback
|
| 143 |
+
this.onInitialsSubmit = onSubmit;
|
| 144 |
+
|
| 145 |
+
// Reset initials state
|
| 146 |
+
this.currentSlot = 0;
|
| 147 |
+
|
| 148 |
+
// Get existing player initials if available
|
| 149 |
+
const profile = this.service.getPlayerProfile();
|
| 150 |
+
if (profile && profile.initials) {
|
| 151 |
+
this.initials = profile.initials.split('');
|
| 152 |
+
} else {
|
| 153 |
+
this.initials = ['A', 'A', 'A'];
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
// Remove existing modal
|
| 157 |
+
this.hideInitialsEntry();
|
| 158 |
+
|
| 159 |
+
// Create modal HTML
|
| 160 |
+
this.initialsModal = document.createElement('div');
|
| 161 |
+
this.initialsModal.className = 'leaderboard-overlay initials-overlay';
|
| 162 |
+
this.initialsModal.innerHTML = `
|
| 163 |
+
<div class="initials-modal">
|
| 164 |
+
<div class="initials-header">
|
| 165 |
+
<h2 class="initials-title">🎉 NEW HIGH SCORE! 🎉</h2>
|
| 166 |
+
<div class="initials-achievement">
|
| 167 |
+
You reached <span class="highlight">Level ${level}</span>!
|
| 168 |
+
<br>
|
| 169 |
+
<span class="rank-text">${this.getRankText(rank)}</span>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
<div class="initials-content">
|
| 174 |
+
<p class="initials-prompt">Enter your initials:</p>
|
| 175 |
+
|
| 176 |
+
<div class="initials-slots">
|
| 177 |
+
${this.initials.map((letter, index) => `
|
| 178 |
+
<div class="initial-slot ${index === 0 ? 'active' : ''}" data-slot="${index}">
|
| 179 |
+
<div class="slot-letter">${letter}</div>
|
| 180 |
+
<div class="slot-arrows">
|
| 181 |
+
<button class="arrow-up" data-slot="${index}" data-direction="up" aria-label="Increase letter">▲</button>
|
| 182 |
+
<button class="arrow-down" data-slot="${index}" data-direction="down" aria-label="Decrease letter">▼</button>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
`).join('')}
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
<div class="initials-instructions">
|
| 189 |
+
<p>Use arrow keys ↑↓ to change letters</p>
|
| 190 |
+
<p>Press Tab or ←→ to move between slots</p>
|
| 191 |
+
<p>Press Enter to confirm</p>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<button class="initials-submit typewriter-button">
|
| 195 |
+
SUBMIT
|
| 196 |
+
</button>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
`;
|
| 200 |
+
|
| 201 |
+
document.body.appendChild(this.initialsModal);
|
| 202 |
+
|
| 203 |
+
// Animate in
|
| 204 |
+
requestAnimationFrame(() => {
|
| 205 |
+
this.initialsModal.classList.add('visible');
|
| 206 |
+
});
|
| 207 |
+
|
| 208 |
+
// Add event listeners
|
| 209 |
+
this.setupInitialsEventListeners();
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
/**
|
| 213 |
+
* Get rank description text
|
| 214 |
+
*/
|
| 215 |
+
getRankText(rank) {
|
| 216 |
+
const ordinal = this.getOrdinal(rank);
|
| 217 |
+
if (rank === 1) return `🥇 ${ordinal} PLACE - TOP SCORE! 🥇`;
|
| 218 |
+
if (rank === 2) return `🥈 ${ordinal} PLACE 🥈`;
|
| 219 |
+
if (rank === 3) return `🥉 ${ordinal} PLACE 🥉`;
|
| 220 |
+
return `${ordinal} place on the leaderboard!`;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
/**
|
| 224 |
+
* Get ordinal suffix for rank (1st, 2nd, 3rd, etc.)
|
| 225 |
+
*/
|
| 226 |
+
getOrdinal(n) {
|
| 227 |
+
const s = ['th', 'st', 'nd', 'rd'];
|
| 228 |
+
const v = n % 100;
|
| 229 |
+
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
/**
|
| 233 |
+
* Setup event listeners for initials entry
|
| 234 |
+
*/
|
| 235 |
+
setupInitialsEventListeners() {
|
| 236 |
+
// Arrow buttons
|
| 237 |
+
this.initialsModal.querySelectorAll('.arrow-up, .arrow-down').forEach(button => {
|
| 238 |
+
button.addEventListener('click', (e) => {
|
| 239 |
+
const slot = parseInt(e.target.dataset.slot);
|
| 240 |
+
const direction = e.target.dataset.direction;
|
| 241 |
+
this.changeInitialLetter(slot, direction === 'up' ? 1 : -1);
|
| 242 |
+
});
|
| 243 |
+
});
|
| 244 |
+
|
| 245 |
+
// Slot clicking to select
|
| 246 |
+
this.initialsModal.querySelectorAll('.initial-slot').forEach(slot => {
|
| 247 |
+
slot.addEventListener('click', (e) => {
|
| 248 |
+
if (!e.target.closest('.arrow-up') && !e.target.closest('.arrow-down')) {
|
| 249 |
+
const slotIndex = parseInt(slot.dataset.slot);
|
| 250 |
+
this.selectSlot(slotIndex);
|
| 251 |
+
}
|
| 252 |
+
});
|
| 253 |
+
});
|
| 254 |
+
|
| 255 |
+
// Submit button
|
| 256 |
+
this.initialsModal.querySelector('.initials-submit').addEventListener('click', () => {
|
| 257 |
+
this.submitInitials();
|
| 258 |
+
});
|
| 259 |
+
|
| 260 |
+
// Keyboard controls
|
| 261 |
+
this.initialsKeyHandler = (e) => {
|
| 262 |
+
switch(e.key) {
|
| 263 |
+
case 'ArrowUp':
|
| 264 |
+
e.preventDefault();
|
| 265 |
+
this.changeInitialLetter(this.currentSlot, 1);
|
| 266 |
+
break;
|
| 267 |
+
case 'ArrowDown':
|
| 268 |
+
e.preventDefault();
|
| 269 |
+
this.changeInitialLetter(this.currentSlot, -1);
|
| 270 |
+
break;
|
| 271 |
+
case 'ArrowLeft':
|
| 272 |
+
e.preventDefault();
|
| 273 |
+
this.selectSlot(Math.max(0, this.currentSlot - 1));
|
| 274 |
+
break;
|
| 275 |
+
case 'ArrowRight':
|
| 276 |
+
case 'Tab':
|
| 277 |
+
e.preventDefault();
|
| 278 |
+
this.selectSlot(Math.min(2, this.currentSlot + 1));
|
| 279 |
+
break;
|
| 280 |
+
case 'Enter':
|
| 281 |
+
e.preventDefault();
|
| 282 |
+
this.submitInitials();
|
| 283 |
+
break;
|
| 284 |
+
}
|
| 285 |
+
};
|
| 286 |
+
document.addEventListener('keydown', this.initialsKeyHandler);
|
| 287 |
+
|
| 288 |
+
// Prevent modal close on backdrop click for initials entry
|
| 289 |
+
this.initialsModal.addEventListener('click', (e) => {
|
| 290 |
+
e.stopPropagation();
|
| 291 |
+
});
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
/**
|
| 295 |
+
* Change letter in current slot
|
| 296 |
+
*/
|
| 297 |
+
changeInitialLetter(slot, delta) {
|
| 298 |
+
const currentChar = this.initials[slot].charCodeAt(0);
|
| 299 |
+
let newChar = currentChar + delta;
|
| 300 |
+
|
| 301 |
+
// Wrap around A-Z
|
| 302 |
+
if (newChar > 90) newChar = 65; // After Z, go to A
|
| 303 |
+
if (newChar < 65) newChar = 90; // Before A, go to Z
|
| 304 |
+
|
| 305 |
+
this.initials[slot] = String.fromCharCode(newChar);
|
| 306 |
+
this.updateInitialsDisplay();
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
/**
|
| 310 |
+
* Select a specific slot
|
| 311 |
+
*/
|
| 312 |
+
selectSlot(slot) {
|
| 313 |
+
this.currentSlot = slot;
|
| 314 |
+
this.initialsModal.querySelectorAll('.initial-slot').forEach((el, index) => {
|
| 315 |
+
el.classList.toggle('active', index === slot);
|
| 316 |
+
});
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
/**
|
| 320 |
+
* Update the visual display of initials
|
| 321 |
+
*/
|
| 322 |
+
updateInitialsDisplay() {
|
| 323 |
+
this.initialsModal.querySelectorAll('.initial-slot').forEach((slot, index) => {
|
| 324 |
+
slot.querySelector('.slot-letter').textContent = this.initials[index];
|
| 325 |
+
});
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
/**
|
| 329 |
+
* Submit initials and save to leaderboard
|
| 330 |
+
*/
|
| 331 |
+
submitInitials() {
|
| 332 |
+
const initialsString = this.initials.join('');
|
| 333 |
+
|
| 334 |
+
// Save to player profile
|
| 335 |
+
const profile = this.service.getPlayerProfile();
|
| 336 |
+
profile.initials = initialsString;
|
| 337 |
+
profile.hasEnteredInitials = true;
|
| 338 |
+
this.service.savePlayerProfile(profile);
|
| 339 |
+
|
| 340 |
+
// Call the callback
|
| 341 |
+
if (this.onInitialsSubmit) {
|
| 342 |
+
this.onInitialsSubmit(initialsString);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
// Hide modal
|
| 346 |
+
this.hideInitialsEntry();
|
| 347 |
+
|
| 348 |
+
// Show success message briefly, then show leaderboard
|
| 349 |
+
this.showSuccessMessage(() => {
|
| 350 |
+
this.show();
|
| 351 |
+
});
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
/**
|
| 355 |
+
* Hide initials entry modal
|
| 356 |
+
*/
|
| 357 |
+
hideInitialsEntry() {
|
| 358 |
+
if (this.initialsModal) {
|
| 359 |
+
this.initialsModal.classList.remove('visible');
|
| 360 |
+
setTimeout(() => {
|
| 361 |
+
if (this.initialsModal && this.initialsModal.parentNode) {
|
| 362 |
+
this.initialsModal.parentNode.removeChild(this.initialsModal);
|
| 363 |
+
}
|
| 364 |
+
this.initialsModal = null;
|
| 365 |
+
}, 300);
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
if (this.initialsKeyHandler) {
|
| 369 |
+
document.removeEventListener('keydown', this.initialsKeyHandler);
|
| 370 |
+
this.initialsKeyHandler = null;
|
| 371 |
+
}
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
/**
|
| 375 |
+
* Show success message after submitting initials
|
| 376 |
+
*/
|
| 377 |
+
showSuccessMessage(onComplete) {
|
| 378 |
+
const successDiv = document.createElement('div');
|
| 379 |
+
successDiv.className = 'leaderboard-overlay visible';
|
| 380 |
+
successDiv.innerHTML = `
|
| 381 |
+
<div class="leaderboard-modal success-message">
|
| 382 |
+
<div class="success-content">
|
| 383 |
+
<h2>✓ SCORE SAVED!</h2>
|
| 384 |
+
<p>Your initials have been added to the leaderboard</p>
|
| 385 |
+
</div>
|
| 386 |
+
</div>
|
| 387 |
+
`;
|
| 388 |
+
|
| 389 |
+
document.body.appendChild(successDiv);
|
| 390 |
+
|
| 391 |
+
setTimeout(() => {
|
| 392 |
+
successDiv.classList.remove('visible');
|
| 393 |
+
setTimeout(() => {
|
| 394 |
+
if (successDiv.parentNode) {
|
| 395 |
+
successDiv.parentNode.removeChild(successDiv);
|
| 396 |
+
}
|
| 397 |
+
if (onComplete) {
|
| 398 |
+
onComplete();
|
| 399 |
+
}
|
| 400 |
+
}, 300);
|
| 401 |
+
}, 1500);
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
/**
|
| 405 |
+
* Show notification toast for milestone achievement
|
| 406 |
+
*/
|
| 407 |
+
showMilestoneNotification(level) {
|
| 408 |
+
const toast = document.createElement('div');
|
| 409 |
+
toast.className = 'milestone-toast';
|
| 410 |
+
toast.innerHTML = `
|
| 411 |
+
<div class="toast-content">
|
| 412 |
+
🎯 Milestone Reached: Level ${level}!
|
| 413 |
+
</div>
|
| 414 |
+
`;
|
| 415 |
+
|
| 416 |
+
document.body.appendChild(toast);
|
| 417 |
+
|
| 418 |
+
// Animate in
|
| 419 |
+
requestAnimationFrame(() => {
|
| 420 |
+
toast.classList.add('visible');
|
| 421 |
+
});
|
| 422 |
+
|
| 423 |
+
// Auto-hide after 3 seconds
|
| 424 |
+
setTimeout(() => {
|
| 425 |
+
toast.classList.remove('visible');
|
| 426 |
+
setTimeout(() => {
|
| 427 |
+
if (toast.parentNode) {
|
| 428 |
+
toast.parentNode.removeChild(toast);
|
| 429 |
+
}
|
| 430 |
+
}, 300);
|
| 431 |
+
}, 3000);
|
| 432 |
+
}
|
| 433 |
+
}
|
src/styles.css
CHANGED
|
@@ -575,28 +575,512 @@
|
|
| 575 |
}
|
| 576 |
}
|
| 577 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
/* Print styles */
|
| 579 |
@media print {
|
| 580 |
body {
|
| 581 |
background: white;
|
| 582 |
color: black;
|
| 583 |
}
|
| 584 |
-
|
| 585 |
.paper-sheet::before {
|
| 586 |
display: none;
|
| 587 |
}
|
| 588 |
-
|
| 589 |
.typewriter-button {
|
| 590 |
display: none;
|
| 591 |
}
|
| 592 |
-
|
| 593 |
.sticky-controls {
|
| 594 |
display: none;
|
| 595 |
}
|
| 596 |
-
|
| 597 |
#game-container {
|
| 598 |
padding-bottom: 0;
|
| 599 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
}
|
| 601 |
}
|
| 602 |
|
|
|
|
| 575 |
}
|
| 576 |
}
|
| 577 |
|
| 578 |
+
/* Leaderboard Button Styling */
|
| 579 |
+
.typewriter-button-small {
|
| 580 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 581 |
+
padding: 8px 16px;
|
| 582 |
+
background-color: var(--aged-paper-dark);
|
| 583 |
+
color: var(--typewriter-ink);
|
| 584 |
+
border: 2px solid black;
|
| 585 |
+
border-radius: 6px;
|
| 586 |
+
font-weight: 600;
|
| 587 |
+
font-size: 14px;
|
| 588 |
+
cursor: pointer;
|
| 589 |
+
transition: all 0.15s ease;
|
| 590 |
+
white-space: nowrap;
|
| 591 |
+
box-shadow:
|
| 592 |
+
0 3px 0 rgba(0, 0, 0, 0.3),
|
| 593 |
+
0 4px 8px rgba(0, 0, 0, 0.1);
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
.typewriter-button-small:hover {
|
| 597 |
+
background-color: rgba(0, 0, 0, 0.05);
|
| 598 |
+
transform: translateY(-1px);
|
| 599 |
+
box-shadow:
|
| 600 |
+
0 4px 0 rgba(0, 0, 0, 0.3),
|
| 601 |
+
0 6px 12px rgba(0, 0, 0, 0.15);
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
.typewriter-button-small:active {
|
| 605 |
+
transform: translateY(2px);
|
| 606 |
+
box-shadow:
|
| 607 |
+
0 1px 0 rgba(0, 0, 0, 0.3),
|
| 608 |
+
0 2px 4px rgba(0, 0, 0, 0.1);
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
/* Leaderboard Overlay */
|
| 612 |
+
.leaderboard-overlay {
|
| 613 |
+
position: fixed;
|
| 614 |
+
top: 0;
|
| 615 |
+
left: 0;
|
| 616 |
+
width: 100%;
|
| 617 |
+
height: 100%;
|
| 618 |
+
background: rgba(0, 0, 0, 0.75);
|
| 619 |
+
display: flex;
|
| 620 |
+
align-items: center;
|
| 621 |
+
justify-content: center;
|
| 622 |
+
z-index: 2000;
|
| 623 |
+
opacity: 0;
|
| 624 |
+
transition: opacity 0.3s ease;
|
| 625 |
+
backdrop-filter: blur(3px);
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
.leaderboard-overlay.visible {
|
| 629 |
+
opacity: 1;
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
/* Leaderboard Modal */
|
| 633 |
+
.leaderboard-modal {
|
| 634 |
+
background: var(--aged-paper-light);
|
| 635 |
+
border: 3px solid rgba(0, 0, 0, 0.4);
|
| 636 |
+
border-radius: 8px;
|
| 637 |
+
padding: 0;
|
| 638 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
| 639 |
+
max-width: 600px;
|
| 640 |
+
width: 90%;
|
| 641 |
+
max-height: 85vh;
|
| 642 |
+
overflow: hidden;
|
| 643 |
+
animation: slideIn 0.3s ease-out;
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
@keyframes slideIn {
|
| 647 |
+
from {
|
| 648 |
+
transform: translateY(-30px);
|
| 649 |
+
opacity: 0;
|
| 650 |
+
}
|
| 651 |
+
to {
|
| 652 |
+
transform: translateY(0);
|
| 653 |
+
opacity: 1;
|
| 654 |
+
}
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
/* Leaderboard Header */
|
| 658 |
+
.leaderboard-header {
|
| 659 |
+
background: linear-gradient(180deg, var(--aged-paper-dark) 0%, #d4c9b3 100%);
|
| 660 |
+
padding: 20px 24px;
|
| 661 |
+
border-bottom: 3px solid rgba(0, 0, 0, 0.3);
|
| 662 |
+
display: flex;
|
| 663 |
+
justify-content: space-between;
|
| 664 |
+
align-items: center;
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
.leaderboard-title {
|
| 668 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
| 669 |
+
font-size: 24px;
|
| 670 |
+
font-weight: 700;
|
| 671 |
+
color: var(--typewriter-ink);
|
| 672 |
+
margin: 0;
|
| 673 |
+
letter-spacing: 1px;
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
.leaderboard-close {
|
| 677 |
+
background: transparent;
|
| 678 |
+
color: var(--typewriter-ink);
|
| 679 |
+
border: 2px solid rgba(0, 0, 0, 0.3);
|
| 680 |
+
border-radius: 4px;
|
| 681 |
+
width: 32px;
|
| 682 |
+
height: 32px;
|
| 683 |
+
font-size: 24px;
|
| 684 |
+
line-height: 1;
|
| 685 |
+
cursor: pointer;
|
| 686 |
+
transition: all 0.2s ease;
|
| 687 |
+
display: flex;
|
| 688 |
+
align-items: center;
|
| 689 |
+
justify-content: center;
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
.leaderboard-close:hover {
|
| 693 |
+
background: rgba(0, 0, 0, 0.1);
|
| 694 |
+
border-color: rgba(0, 0, 0, 0.5);
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
.leaderboard-close:active {
|
| 698 |
+
background: rgba(0, 0, 0, 0.15);
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
/* Leaderboard Content */
|
| 702 |
+
.leaderboard-content {
|
| 703 |
+
padding: 24px;
|
| 704 |
+
max-height: calc(85vh - 100px);
|
| 705 |
+
overflow-y: auto;
|
| 706 |
+
background: var(--aged-paper-light);
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
/* Leaderboard List */
|
| 710 |
+
.leaderboard-list {
|
| 711 |
+
display: flex;
|
| 712 |
+
flex-direction: column;
|
| 713 |
+
gap: 8px;
|
| 714 |
+
margin-bottom: 20px;
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
.leaderboard-entry {
|
| 718 |
+
display: grid;
|
| 719 |
+
grid-template-columns: 50px 80px 1fr;
|
| 720 |
+
align-items: center;
|
| 721 |
+
gap: 12px;
|
| 722 |
+
padding: 14px 16px;
|
| 723 |
+
background: var(--aged-paper-dark);
|
| 724 |
+
border: 2px solid rgba(0, 0, 0, 0.2);
|
| 725 |
+
border-radius: 6px;
|
| 726 |
+
font-family: 'Courier New', monospace;
|
| 727 |
+
transition: all 0.2s ease;
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
/* Rank Colors */
|
| 731 |
+
.leaderboard-entry.rank-gold {
|
| 732 |
+
background: linear-gradient(135deg, #fff7e6 0%, #ffeaa7 100%);
|
| 733 |
+
border-color: #d4af37;
|
| 734 |
+
border-width: 3px;
|
| 735 |
+
box-shadow: 0 2px 8px rgba(212, 175, 55, 0.3);
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
.leaderboard-entry.rank-silver {
|
| 739 |
+
background: linear-gradient(135deg, #fff9f2 0%, #f5deb3 100%);
|
| 740 |
+
border-color: #c9a869;
|
| 741 |
+
border-width: 2px;
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
.leaderboard-entry.rank-standard {
|
| 745 |
+
background: var(--aged-paper-dark);
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
.leaderboard-entry.player-entry {
|
| 749 |
+
background: linear-gradient(135deg, #f0e6ff 0%, #e0d1f5 100%);
|
| 750 |
+
border-color: #8b7aa8;
|
| 751 |
+
border-width: 3px;
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
.leaderboard-entry:hover {
|
| 755 |
+
transform: translateX(4px);
|
| 756 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
.entry-rank {
|
| 760 |
+
font-size: 20px;
|
| 761 |
+
font-weight: 700;
|
| 762 |
+
color: var(--typewriter-ink);
|
| 763 |
+
text-align: center;
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
+
.entry-initials {
|
| 767 |
+
font-size: 18px;
|
| 768 |
+
font-weight: 700;
|
| 769 |
+
color: var(--typewriter-ink);
|
| 770 |
+
letter-spacing: 2px;
|
| 771 |
+
text-align: center;
|
| 772 |
+
font-family: 'Courier New', monospace;
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
.entry-score {
|
| 776 |
+
font-size: 16px;
|
| 777 |
+
font-weight: 600;
|
| 778 |
+
color: #666;
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
.entry-round {
|
| 782 |
+
font-size: 14px;
|
| 783 |
+
color: #999;
|
| 784 |
+
font-weight: 400;
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
/* Empty State */
|
| 788 |
+
.leaderboard-empty {
|
| 789 |
+
text-align: center;
|
| 790 |
+
padding: 40px 20px;
|
| 791 |
+
color: #666;
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
.leaderboard-empty p {
|
| 795 |
+
margin: 8px 0;
|
| 796 |
+
font-size: 16px;
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
/* Player Stats */
|
| 800 |
+
.leaderboard-player-stats {
|
| 801 |
+
margin-top: 24px;
|
| 802 |
+
padding: 18px;
|
| 803 |
+
background: rgba(139, 115, 170, 0.08);
|
| 804 |
+
border: 2px solid rgba(139, 115, 170, 0.25);
|
| 805 |
+
border-radius: 6px;
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
.player-best {
|
| 809 |
+
font-size: 16px;
|
| 810 |
+
font-weight: 600;
|
| 811 |
+
color: var(--typewriter-ink);
|
| 812 |
+
margin-bottom: 8px;
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
.player-best .highlight {
|
| 816 |
+
color: #6b4a8e;
|
| 817 |
+
font-weight: 700;
|
| 818 |
+
}
|
| 819 |
+
|
| 820 |
+
.player-stats-details {
|
| 821 |
+
display: flex;
|
| 822 |
+
flex-direction: column;
|
| 823 |
+
gap: 4px;
|
| 824 |
+
font-size: 14px;
|
| 825 |
+
color: #666;
|
| 826 |
+
}
|
| 827 |
+
|
| 828 |
+
/* Initials Entry Modal */
|
| 829 |
+
.initials-overlay {
|
| 830 |
+
background: rgba(0, 0, 0, 0.9);
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
.initials-modal {
|
| 834 |
+
max-width: 500px;
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
.initials-header {
|
| 838 |
+
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
| 839 |
+
padding: 24px;
|
| 840 |
+
border-bottom: 3px solid black;
|
| 841 |
+
text-align: center;
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
.initials-title {
|
| 845 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 846 |
+
font-size: 24px;
|
| 847 |
+
font-weight: 700;
|
| 848 |
+
color: white;
|
| 849 |
+
margin: 0 0 12px 0;
|
| 850 |
+
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
.initials-achievement {
|
| 854 |
+
font-size: 16px;
|
| 855 |
+
color: rgba(255, 255, 255, 0.95);
|
| 856 |
+
line-height: 1.6;
|
| 857 |
+
}
|
| 858 |
+
|
| 859 |
+
.initials-achievement .highlight {
|
| 860 |
+
color: #fde68a;
|
| 861 |
+
font-weight: 700;
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
.rank-text {
|
| 865 |
+
font-size: 14px;
|
| 866 |
+
color: #fde68a;
|
| 867 |
+
font-weight: 600;
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
.initials-content {
|
| 871 |
+
padding: 32px 24px;
|
| 872 |
+
text-align: center;
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
.initials-prompt {
|
| 876 |
+
font-size: 18px;
|
| 877 |
+
font-weight: 600;
|
| 878 |
+
color: var(--typewriter-ink);
|
| 879 |
+
margin-bottom: 24px;
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
/* Initials Slots */
|
| 883 |
+
.initials-slots {
|
| 884 |
+
display: flex;
|
| 885 |
+
justify-content: center;
|
| 886 |
+
gap: 16px;
|
| 887 |
+
margin-bottom: 24px;
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
.initial-slot {
|
| 891 |
+
display: flex;
|
| 892 |
+
flex-direction: column;
|
| 893 |
+
align-items: center;
|
| 894 |
+
gap: 8px;
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
.initial-slot.active .slot-letter {
|
| 898 |
+
border-color: #8b5cf6;
|
| 899 |
+
background: rgba(139, 92, 246, 0.1);
|
| 900 |
+
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
.slot-letter {
|
| 904 |
+
width: 64px;
|
| 905 |
+
height: 80px;
|
| 906 |
+
display: flex;
|
| 907 |
+
align-items: center;
|
| 908 |
+
justify-content: center;
|
| 909 |
+
font-size: 48px;
|
| 910 |
+
font-weight: 700;
|
| 911 |
+
font-family: 'Courier New', monospace;
|
| 912 |
+
color: var(--typewriter-ink);
|
| 913 |
+
background: var(--aged-paper-dark);
|
| 914 |
+
border: 3px solid black;
|
| 915 |
+
border-radius: 8px;
|
| 916 |
+
transition: all 0.2s ease;
|
| 917 |
+
box-shadow: 0 4px 0 rgba(0, 0, 0, 0.3);
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
.slot-arrows {
|
| 921 |
+
display: flex;
|
| 922 |
+
flex-direction: column;
|
| 923 |
+
gap: 4px;
|
| 924 |
+
}
|
| 925 |
+
|
| 926 |
+
.arrow-up, .arrow-down {
|
| 927 |
+
width: 40px;
|
| 928 |
+
height: 32px;
|
| 929 |
+
background: var(--aged-paper-dark);
|
| 930 |
+
border: 2px solid black;
|
| 931 |
+
border-radius: 4px;
|
| 932 |
+
font-size: 16px;
|
| 933 |
+
cursor: pointer;
|
| 934 |
+
transition: all 0.15s ease;
|
| 935 |
+
display: flex;
|
| 936 |
+
align-items: center;
|
| 937 |
+
justify-content: center;
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
.arrow-up:hover, .arrow-down:hover {
|
| 941 |
+
background: rgba(0, 0, 0, 0.05);
|
| 942 |
+
transform: scale(1.1);
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
+
.arrow-up:active, .arrow-down:active {
|
| 946 |
+
transform: scale(0.95);
|
| 947 |
+
}
|
| 948 |
+
|
| 949 |
+
/* Initials Instructions */
|
| 950 |
+
.initials-instructions {
|
| 951 |
+
margin-bottom: 24px;
|
| 952 |
+
color: #666;
|
| 953 |
+
font-size: 13px;
|
| 954 |
+
line-height: 1.6;
|
| 955 |
+
}
|
| 956 |
+
|
| 957 |
+
.initials-instructions p {
|
| 958 |
+
margin: 4px 0;
|
| 959 |
+
}
|
| 960 |
+
|
| 961 |
+
.initials-submit {
|
| 962 |
+
width: 100%;
|
| 963 |
+
max-width: 200px;
|
| 964 |
+
min-height: 48px;
|
| 965 |
+
font-size: 16px;
|
| 966 |
+
}
|
| 967 |
+
|
| 968 |
+
/* Success Message */
|
| 969 |
+
.success-message {
|
| 970 |
+
max-width: 400px;
|
| 971 |
+
padding: 32px;
|
| 972 |
+
text-align: center;
|
| 973 |
+
}
|
| 974 |
+
|
| 975 |
+
.success-content h2 {
|
| 976 |
+
font-size: 32px;
|
| 977 |
+
color: #10b981;
|
| 978 |
+
margin-bottom: 12px;
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
+
.success-content p {
|
| 982 |
+
font-size: 16px;
|
| 983 |
+
color: #666;
|
| 984 |
+
}
|
| 985 |
+
|
| 986 |
+
/* Milestone Toast */
|
| 987 |
+
.milestone-toast {
|
| 988 |
+
position: fixed;
|
| 989 |
+
top: 20px;
|
| 990 |
+
left: 50%;
|
| 991 |
+
transform: translateX(-50%) translateY(-100px);
|
| 992 |
+
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
| 993 |
+
color: white;
|
| 994 |
+
padding: 16px 24px;
|
| 995 |
+
border-radius: 12px;
|
| 996 |
+
border: 3px solid black;
|
| 997 |
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
| 998 |
+
z-index: 3000;
|
| 999 |
+
transition: transform 0.3s ease;
|
| 1000 |
+
font-weight: 600;
|
| 1001 |
+
font-size: 18px;
|
| 1002 |
+
}
|
| 1003 |
+
|
| 1004 |
+
.milestone-toast.visible {
|
| 1005 |
+
transform: translateX(-50%) translateY(0);
|
| 1006 |
+
}
|
| 1007 |
+
|
| 1008 |
+
/* Mobile Responsive */
|
| 1009 |
+
@media (max-width: 640px) {
|
| 1010 |
+
.leaderboard-modal {
|
| 1011 |
+
width: 95%;
|
| 1012 |
+
max-height: 90vh;
|
| 1013 |
+
}
|
| 1014 |
+
|
| 1015 |
+
.leaderboard-title {
|
| 1016 |
+
font-size: 20px;
|
| 1017 |
+
}
|
| 1018 |
+
|
| 1019 |
+
.leaderboard-entry {
|
| 1020 |
+
grid-template-columns: 40px 70px 1fr;
|
| 1021 |
+
gap: 8px;
|
| 1022 |
+
padding: 12px;
|
| 1023 |
+
}
|
| 1024 |
+
|
| 1025 |
+
.entry-rank {
|
| 1026 |
+
font-size: 16px;
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
.entry-initials {
|
| 1030 |
+
font-size: 16px;
|
| 1031 |
+
}
|
| 1032 |
+
|
| 1033 |
+
.entry-score {
|
| 1034 |
+
font-size: 14px;
|
| 1035 |
+
}
|
| 1036 |
+
|
| 1037 |
+
.initials-modal {
|
| 1038 |
+
width: 95%;
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
.slot-letter {
|
| 1042 |
+
width: 56px;
|
| 1043 |
+
height: 70px;
|
| 1044 |
+
font-size: 40px;
|
| 1045 |
+
}
|
| 1046 |
+
|
| 1047 |
+
.initials-slots {
|
| 1048 |
+
gap: 12px;
|
| 1049 |
+
}
|
| 1050 |
+
|
| 1051 |
+
.typewriter-button-small {
|
| 1052 |
+
font-size: 13px;
|
| 1053 |
+
padding: 8px 18px;
|
| 1054 |
+
}
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
/* Print styles */
|
| 1058 |
@media print {
|
| 1059 |
body {
|
| 1060 |
background: white;
|
| 1061 |
color: black;
|
| 1062 |
}
|
| 1063 |
+
|
| 1064 |
.paper-sheet::before {
|
| 1065 |
display: none;
|
| 1066 |
}
|
| 1067 |
+
|
| 1068 |
.typewriter-button {
|
| 1069 |
display: none;
|
| 1070 |
}
|
| 1071 |
+
|
| 1072 |
.sticky-controls {
|
| 1073 |
display: none;
|
| 1074 |
}
|
| 1075 |
+
|
| 1076 |
#game-container {
|
| 1077 |
padding-bottom: 0;
|
| 1078 |
}
|
| 1079 |
+
|
| 1080 |
+
.leaderboard-overlay,
|
| 1081 |
+
.milestone-toast {
|
| 1082 |
+
display: none;
|
| 1083 |
+
}
|
| 1084 |
}
|
| 1085 |
}
|
| 1086 |
|
src/welcomeOverlay.js
CHANGED
|
@@ -43,15 +43,15 @@ class WelcomeOverlay {
|
|
| 43 |
|
| 44 |
<div class="welcome-content">
|
| 45 |
<p>
|
| 46 |
-
<strong>How to play:</strong> Fill in the blanks
|
| 47 |
</p>
|
| 48 |
-
|
| 49 |
<p>
|
| 50 |
-
<strong>Data source:</strong>
|
| 51 |
</p>
|
| 52 |
-
|
| 53 |
<p style="margin-bottom: 0;">
|
| 54 |
-
<strong>AI assistance:</strong> Powered by Google's Gemma
|
| 55 |
</p>
|
| 56 |
</div>
|
| 57 |
|
|
|
|
| 43 |
|
| 44 |
<div class="welcome-content">
|
| 45 |
<p>
|
| 46 |
+
<strong>How to play:</strong> Fill in the blanks to advance through levels with increasing difficulty and vocabulary complexity.
|
| 47 |
</p>
|
| 48 |
+
|
| 49 |
<p>
|
| 50 |
+
<strong>Data source:</strong> Excerpted historical and literary texts from Project Gutenberg's public domain collection, processed via Hugging Face Datasets.
|
| 51 |
</p>
|
| 52 |
+
|
| 53 |
<p style="margin-bottom: 0;">
|
| 54 |
+
<strong>AI assistance:</strong> Powered by Google's Gemma-3-27b model via OpenRouter for word selection, hints, and contextualization.
|
| 55 |
</p>
|
| 56 |
</div>
|
| 57 |
|