Koddenbrock commited on
Commit
944af38
·
1 Parent(s): 6d65c46

add initial game implementation with HTML, CSS, and JavaScript

Browse files
.idea/.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
4
+ # Ignored default folder with query files
5
+ /queries/
6
+ # Datasource local storage ignored files
7
+ /dataSources/
8
+ /dataSources.local.xml
9
+ # Editor-based HTTP Client requests
10
+ /httpRequests/
.idea/DiffMT.iml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="PYTHON_MODULE" version="4">
3
+ <component name="NewModuleRootManager">
4
+ <content url="file://$MODULE_DIR$" />
5
+ <orderEntry type="jdk" jdkName="Python 3.13" jdkType="Python SDK" />
6
+ <orderEntry type="sourceFolder" forTests="false" />
7
+ </component>
8
+ </module>
.idea/inspectionProfiles/Project_Default.xml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <component name="InspectionProjectProfileManager">
2
+ <profile version="1.0">
3
+ <option name="myName" value="Project Default" />
4
+ <inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
5
+ <option name="ignoredPackages">
6
+ <list>
7
+ <option value="pytorch" />
8
+ </list>
9
+ </option>
10
+ </inspection_tool>
11
+ <inspection_tool class="PyStubPackagesAdvertiser" enabled="true" level="WARNING" enabled_by_default="true">
12
+ <option name="ignoredPackages">
13
+ <list>
14
+ <option value="pandas" />
15
+ </list>
16
+ </option>
17
+ </inspection_tool>
18
+ </profile>
19
+ </component>
.idea/inspectionProfiles/profiles_settings.xml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ <component name="InspectionProjectProfileManager">
2
+ <settings>
3
+ <option name="USE_PROJECT_PROFILE" value="false" />
4
+ <version value="1.0" />
5
+ </settings>
6
+ </component>
.idea/misc.xml ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13" project-jdk-type="Python SDK" />
4
+ </project>
.idea/modules.xml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/DiffMT.iml" filepath="$PROJECT_DIR$/.idea/DiffMT.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
.idea/vcs.xml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="" vcs="Git" />
5
+ <mapping directory="$PROJECT_DIR$" vcs="Git" />
6
+ </component>
7
+ </project>
package.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "microtubule-game",
3
+ "version": "1.0.0",
4
+ "description": "2AFC microtubule real-vs-fake image rating game",
5
+ "main": "server.js",
6
+ "scripts": {
7
+ "start": "node server.js"
8
+ },
9
+ "dependencies": {
10
+ "express": "^4.18.2"
11
+ }
12
+ }
public/game.js ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ const MAX_ROUNDS = 10;
4
+
5
+ // ── State ──────────────────────────────────────────────────────
6
+ const state = {
7
+ images: { real: [], fake: [] },
8
+ pairs: [], // [{real, fake, realOnLeft}]
9
+ current: 0,
10
+ score: 0,
11
+ trialLog: [], // 2AFC trial records accumulated client-side
12
+ trialStart: 0,
13
+ locked: false,
14
+ sessionId: null
15
+ };
16
+
17
+ // ── Helpers ────────────────────────────────────────────────────
18
+ const $ = id => document.getElementById(id);
19
+
20
+ function showScreen(id) {
21
+ document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
22
+ $(id).classList.add('active');
23
+ }
24
+
25
+ function shuffle(arr) {
26
+ const a = [...arr];
27
+ for (let i = a.length - 1; i > 0; i--) {
28
+ const j = Math.floor(Math.random() * (i + 1));
29
+ [a[i], a[j]] = [a[j], a[i]];
30
+ }
31
+ return a;
32
+ }
33
+
34
+ function escapeHtml(str) {
35
+ return String(str).replace(/[&<>"']/g, c =>
36
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])
37
+ );
38
+ }
39
+
40
+ // ── Initialise ─────────────────────────────────────────────────
41
+ async function init() {
42
+ // Wire up static buttons
43
+ $('btn-start').addEventListener('click', startGame);
44
+ $('btn-submit').addEventListener('click', submitScore);
45
+ $('btn-skip').addEventListener('click', () => loadLeaderboard(null));
46
+ $('btn-again').addEventListener('click', () => location.reload());
47
+ $('panel-left').addEventListener('click', () => choose('left'));
48
+ $('panel-right').addEventListener('click', () => choose('right'));
49
+
50
+ // Keyboard shortcuts
51
+ document.addEventListener('keydown', e => {
52
+ const gameActive = $('screen-game').classList.contains('active');
53
+ const nameActive = $('screen-name').classList.contains('active');
54
+ if (gameActive) {
55
+ if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') choose('left');
56
+ if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') choose('right');
57
+ }
58
+ if (nameActive && e.key === 'Enter') submitScore();
59
+ });
60
+
61
+ // Fetch available images
62
+ try {
63
+ const res = await fetch('/api/images');
64
+ const data = await res.json();
65
+ state.images = data;
66
+
67
+ const n = Math.min(data.real.length, data.fake.length, MAX_ROUNDS);
68
+
69
+ if (n === 0) {
70
+ $('img-count').textContent =
71
+ 'No images found yet — add files to images/real/ and images/fake/';
72
+ return;
73
+ }
74
+
75
+ $('img-count').textContent =
76
+ `${n} round${n !== 1 ? 's' : ''} ready (${data.real.length} real · ${data.fake.length} fake)`;
77
+ $('btn-start').textContent = 'Start Challenge';
78
+ $('btn-start').disabled = false;
79
+ } catch (err) {
80
+ $('img-count').textContent = 'Could not load images: ' + err.message;
81
+ }
82
+ }
83
+
84
+ // ── Game start ─────────────────────────────────────────────────
85
+ function startGame() {
86
+ const real = shuffle(state.images.real);
87
+ const fake = shuffle(state.images.fake);
88
+ const n = Math.min(real.length, fake.length, MAX_ROUNDS);
89
+
90
+ state.pairs = Array.from({ length: n }, (_, i) => ({
91
+ real: real[i],
92
+ fake: fake[i],
93
+ realOnLeft: Math.random() < 0.5
94
+ }));
95
+ state.current = 0;
96
+ state.score = 0;
97
+ state.trialLog = [];
98
+ state.locked = false;
99
+
100
+ showScreen('screen-game');
101
+ loadTrial();
102
+ }
103
+
104
+ // ── Load a trial ───────────────────────────────────────────────
105
+ function loadTrial() {
106
+ const { pairs, current } = state;
107
+ const pair = pairs[current];
108
+ const left = pair.realOnLeft ? pair.real : pair.fake;
109
+ const right = pair.realOnLeft ? pair.fake : pair.real;
110
+
111
+ // Header
112
+ $('round-label').textContent = `Round ${current + 1} of ${pairs.length}`;
113
+ $('score-label').textContent = `Score: ${state.score}`;
114
+ $('progress-fill').style.width = `${(current / pairs.length) * 100}%`;
115
+
116
+ // Images
117
+ $('img-left').src = left.src;
118
+ $('img-right').src = right.src;
119
+
120
+ // Reset panel states
121
+ ['panel-left', 'panel-right'].forEach(id => {
122
+ $(id).classList.remove('disabled', 'correct', 'incorrect');
123
+ });
124
+
125
+ // Clear feedback
126
+ const fb = $('feedback-bar');
127
+ fb.className = 'feedback-bar';
128
+ fb.textContent = '';
129
+
130
+ state.locked = false;
131
+ state.trialStart = Date.now();
132
+
133
+ // Preload next trial's images in background
134
+ if (current + 1 < pairs.length) {
135
+ const next = pairs[current + 1];
136
+ [next.real.src, next.fake.src].forEach(src => { const i = new Image(); i.src = src; });
137
+ }
138
+ }
139
+
140
+ // ── Handle a choice ────────────────────────────────────��───────
141
+ function choose(side) {
142
+ if (state.locked) return;
143
+ state.locked = true;
144
+
145
+ const pair = state.pairs[state.current];
146
+ const reactionMs = Date.now() - state.trialStart;
147
+
148
+ const leftImg = pair.realOnLeft ? pair.real : pair.fake;
149
+ const rightImg = pair.realOnLeft ? pair.fake : pair.real;
150
+ const selectedImg = side === 'left' ? leftImg : rightImg;
151
+ const correct = (side === 'left') === pair.realOnLeft;
152
+
153
+ if (correct) state.score++;
154
+
155
+ // ── 2AFC trial record ──────────────────────────────────────
156
+ state.trialLog.push({
157
+ trial_number: state.current + 1,
158
+ image_left: leftImg.id,
159
+ image_right: rightImg.id,
160
+ real_image: pair.real.id,
161
+ fake_image: pair.fake.id,
162
+ selected_side: side,
163
+ selected_image: selectedImg.id,
164
+ correct,
165
+ reaction_time_ms: reactionMs
166
+ });
167
+
168
+ // ── Highlight panels ───────────────────────────────────────
169
+ $('panel-left').classList.add('disabled');
170
+ $('panel-right').classList.add('disabled');
171
+
172
+ const realPanelId = pair.realOnLeft ? 'panel-left' : 'panel-right';
173
+ const selectedPanelId = side === 'left' ? 'panel-left' : 'panel-right';
174
+
175
+ $(realPanelId).classList.add('correct');
176
+ if (!correct) $(selectedPanelId).classList.add('incorrect');
177
+
178
+ // ── Feedback bar ───────────────────────────────────────────
179
+ const fb = $('feedback-bar');
180
+ const realSide = pair.realOnLeft ? 'left (A)' : 'right (B)';
181
+ if (correct) {
182
+ fb.textContent = '✓ Correct — that was the real micrograph.';
183
+ fb.className = 'feedback-bar correct-fb';
184
+ } else {
185
+ fb.textContent = `✗ Wrong — the real image was on the ${realSide}.`;
186
+ fb.className = 'feedback-bar incorrect-fb';
187
+ }
188
+
189
+ setTimeout(advance, 1700);
190
+ }
191
+
192
+ function advance() {
193
+ state.current++;
194
+ if (state.current >= state.pairs.length) finishGame();
195
+ else loadTrial();
196
+ }
197
+
198
+ // ── Finish game ────────────────────────────────────────────────
199
+ function finishGame() {
200
+ $('progress-fill').style.width = '100%';
201
+
202
+ const pct = Math.round((state.score / state.pairs.length) * 100);
203
+ $('final-score').innerHTML = `
204
+ <div class="score-big">${state.score}<span style="font-size:1.5rem;color:var(--muted)"> / ${state.pairs.length}</span></div>
205
+ <div class="score-sub">${pct}% accuracy</div>
206
+ `;
207
+
208
+ showScreen('screen-name');
209
+ setTimeout(() => $('name-input').focus(), 80);
210
+ }
211
+
212
+ // ── Submit score ───────────────────────────────────────────────
213
+ async function submitScore() {
214
+ const name = $('name-input').value.trim();
215
+ if (!name) {
216
+ $('name-input').classList.add('error');
217
+ $('name-input').focus();
218
+ return;
219
+ }
220
+ $('name-input').classList.remove('error');
221
+ $('btn-submit').disabled = true;
222
+ $('btn-submit').textContent = 'Saving…';
223
+
224
+ try {
225
+ const res = await fetch('/api/submit', {
226
+ method: 'POST',
227
+ headers: { 'Content-Type': 'application/json' },
228
+ body: JSON.stringify({ playerName: name, trials: state.trialLog })
229
+ });
230
+ const result = await res.json();
231
+ state.sessionId = result.sessionId;
232
+ await loadLeaderboard(name);
233
+ } catch {
234
+ $('btn-submit').disabled = false;
235
+ $('btn-submit').textContent = 'Save Score';
236
+ alert('Could not reach the server. Please try again.');
237
+ }
238
+ }
239
+
240
+ // ── Leaderboard ────────────────────────────────────────────────
241
+ async function loadLeaderboard(playerName) {
242
+ showScreen('screen-results');
243
+
244
+ const pct = Math.round((state.score / state.pairs.length) * 100);
245
+ $('your-result').innerHTML = playerName
246
+ ? `<strong>${escapeHtml(playerName)}</strong> — you scored <strong>${state.score}&thinsp;/&thinsp;${state.pairs.length}</strong> &nbsp;(${pct}% accuracy)`
247
+ : `Your score: <strong>${state.score}&thinsp;/&thinsp;${state.pairs.length}</strong> &nbsp;(${pct}% accuracy) — <em>not saved</em>`;
248
+
249
+ try {
250
+ const entries = await fetch('/api/leaderboard').then(r => r.json());
251
+ renderLeaderboard(entries, playerName);
252
+ } catch {
253
+ $('lb-body').innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--muted)">Could not load leaderboard</td></tr>';
254
+ }
255
+ }
256
+
257
+ function renderLeaderboard(entries, playerName) {
258
+ const medals = ['🥇', '🥈', '🥉'];
259
+ const tbody = $('lb-body');
260
+ tbody.innerHTML = '';
261
+
262
+ if (!entries.length) {
263
+ tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--muted)">No scores yet — be the first!</td></tr>';
264
+ return;
265
+ }
266
+
267
+ entries.forEach((entry, i) => {
268
+ const isMe = playerName && entry.player_name === playerName && entry.session_id === state.sessionId;
269
+ const tr = document.createElement('tr');
270
+ if (isMe) tr.classList.add('highlight');
271
+
272
+ const rank = medals[i] ?? (i + 1);
273
+ const date = new Date(entry.timestamp).toLocaleDateString('en-GB', {
274
+ day: '2-digit', month: 'short', year: '2-digit'
275
+ });
276
+
277
+ tr.innerHTML = `
278
+ <td>${rank}</td>
279
+ <td>${escapeHtml(entry.player_name)}${isMe ? ' <em style="color:var(--muted);font-style:normal;font-size:.85em">(you)</em>' : ''}</td>
280
+ <td>${entry.score} / ${entry.total}</td>
281
+ <td>${entry.percentage}%</td>
282
+ <td style="color:var(--muted)">${date}</td>
283
+ `;
284
+ tbody.appendChild(tr);
285
+ });
286
+ }
287
+
288
+ // ── Boot ───────────────────────────────────────────────────────
289
+ init();
public/index.html ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Real or Fake? — Microtubule Challenge</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ </head>
9
+ <body>
10
+
11
+ <!-- ── Welcome ─────────────────────────────────────────────── -->
12
+ <div id="screen-welcome" class="screen active">
13
+ <div class="card">
14
+ <div class="badge">Microtubule Challenge</div>
15
+ <h1>Real or Fake?</h1>
16
+ <p class="lead">Can you tell real microscopy images from AI-generated ones?</p>
17
+ <div class="info-box">
18
+ <p>You will see pairs of microtubule micrographs. One is from a real microscope — the other was generated by a diffusion model.</p>
19
+ <p><strong>Your task:</strong> click the image you think is <strong>real</strong>.</p>
20
+ <p class="hint">Keyboard: <kbd>A</kbd> or <kbd>←</kbd> for left &nbsp;·&nbsp; <kbd>D</kbd> or <kbd>→</kbd> for right</p>
21
+ </div>
22
+ <div id="img-count" class="img-count"></div>
23
+ <button id="btn-start" class="btn-primary" disabled>Loading…</button>
24
+ </div>
25
+ </div>
26
+
27
+ <!-- ── Game ───────────────────────────────────────────────── -->
28
+ <div id="screen-game" class="screen">
29
+ <header class="game-header">
30
+ <span id="round-label" class="header-stat">Round 1 of 10</span>
31
+ <div class="progress-bar"><div id="progress-fill"></div></div>
32
+ <span id="score-label" class="header-stat right">Score: 0</span>
33
+ </header>
34
+
35
+ <main class="game-main">
36
+ <h2 class="prompt">Which image is <em>real</em>?</h2>
37
+
38
+ <div class="arena">
39
+ <div id="panel-left" class="img-panel">
40
+ <span class="panel-label">A</span>
41
+ <img id="img-left" alt="Option A" draggable="false">
42
+ </div>
43
+
44
+ <div class="vs-divider"><span>VS</span></div>
45
+
46
+ <div id="panel-right" class="img-panel">
47
+ <span class="panel-label">B</span>
48
+ <img id="img-right" alt="Option B" draggable="false">
49
+ </div>
50
+ </div>
51
+
52
+ <div id="feedback-bar" class="feedback-bar"></div>
53
+ </main>
54
+ </div>
55
+
56
+ <!-- ── Name entry ─────────────────────────────────────────── -->
57
+ <div id="screen-name" class="screen">
58
+ <div class="card">
59
+ <h2>Challenge complete!</h2>
60
+ <div id="final-score" class="final-score"></div>
61
+ <p class="enter-name-label">Enter your name to save your score:</p>
62
+ <div class="input-row">
63
+ <input id="name-input" class="text-input" type="text" maxlength="40"
64
+ placeholder="Your name…" autocomplete="off" spellcheck="false">
65
+ <button id="btn-submit" class="btn-primary inline">Save Score</button>
66
+ </div>
67
+ <button id="btn-skip" class="btn-ghost">Skip — don't save</button>
68
+ </div>
69
+ </div>
70
+
71
+ <!-- ── Results / Leaderboard ──────────────────────────────── -->
72
+ <div id="screen-results" class="screen">
73
+ <div class="results-wrap">
74
+ <h2>Leaderboard</h2>
75
+ <div id="your-result" class="your-result"></div>
76
+ <div class="table-wrap">
77
+ <table class="lb-table">
78
+ <thead>
79
+ <tr><th>#</th><th>Name</th><th>Score</th><th>Accuracy</th><th>Date</th></tr>
80
+ </thead>
81
+ <tbody id="lb-body"></tbody>
82
+ </table>
83
+ </div>
84
+ <div class="results-actions">
85
+ <button id="btn-again" class="btn-primary">Play Again</button>
86
+ <a id="export-link" href="/api/export" class="btn-ghost" download="2afc_data.json">Export Data (JSON)</a>
87
+ </div>
88
+ </div>
89
+ </div>
90
+
91
+ <script src="game.js"></script>
92
+ </body>
93
+ </html>
public/style.css ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2
+
3
+ :root {
4
+ --bg: #0d1117;
5
+ --surface: #161b22;
6
+ --surface2: #21262d;
7
+ --border: #30363d;
8
+ --accent: #58a6ff;
9
+ --green: #3fb950;
10
+ --red: #f85149;
11
+ --text: #e6edf3;
12
+ --muted: #8b949e;
13
+ --r: 10px;
14
+ --font: system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
15
+ }
16
+
17
+ html { scroll-behavior: smooth; }
18
+
19
+ body {
20
+ font-family: var(--font);
21
+ background: var(--bg);
22
+ color: var(--text);
23
+ min-height: 100dvh;
24
+ line-height: 1.5;
25
+ }
26
+
27
+ /* ── Screens ─────────────────────────────────────────────────── */
28
+ .screen { display: none; }
29
+ .screen.active { display: flex; flex-direction: column; align-items: center; min-height: 100dvh; }
30
+ #screen-welcome.active, #screen-name.active { justify-content: center; padding: 2rem 1rem; }
31
+ #screen-results.active { justify-content: flex-start; padding: 2rem 1rem; }
32
+
33
+ /* ── Card ────────────────────────────────────────────────────── */
34
+ .card {
35
+ background: var(--surface);
36
+ border: 1px solid var(--border);
37
+ border-radius: 16px;
38
+ padding: 2.5rem;
39
+ width: min(540px, 100%);
40
+ }
41
+
42
+ /* ── Welcome ─────────────────────────────────────────────────── */
43
+ .badge {
44
+ display: inline-block;
45
+ background: var(--surface2);
46
+ color: var(--muted);
47
+ font-size: 0.72rem;
48
+ font-weight: 600;
49
+ letter-spacing: 0.12em;
50
+ text-transform: uppercase;
51
+ padding: 0.25rem 0.7rem;
52
+ border-radius: 999px;
53
+ border: 1px solid var(--border);
54
+ margin-bottom: 1.1rem;
55
+ }
56
+
57
+ h1 { font-size: 2.4rem; font-weight: 700; margin-bottom: 0.4rem; }
58
+ h2 { font-size: 1.6rem; font-weight: 700; margin-bottom: 1.25rem; }
59
+
60
+ .lead { color: var(--muted); font-size: 1.05rem; margin-bottom: 1.5rem; }
61
+
62
+ .info-box {
63
+ background: var(--surface2);
64
+ border: 1px solid var(--border);
65
+ border-radius: var(--r);
66
+ padding: 1.1rem 1.25rem;
67
+ margin-bottom: 1.25rem;
68
+ font-size: 0.95rem;
69
+ line-height: 1.7;
70
+ }
71
+ .info-box p + p { margin-top: 0.45rem; }
72
+ .hint { color: var(--muted); font-size: 0.84rem; }
73
+
74
+ kbd {
75
+ background: var(--bg);
76
+ border: 1px solid var(--border);
77
+ border-radius: 4px;
78
+ padding: 1px 5px;
79
+ font-size: 0.8em;
80
+ font-family: monospace;
81
+ }
82
+
83
+ .img-count { color: var(--muted); font-size: 0.88rem; margin-bottom: 1.25rem; min-height: 1.2em; }
84
+
85
+ /* ── Buttons ─────────────────────────────────────────────────── */
86
+ .btn-primary {
87
+ display: block;
88
+ width: 100%;
89
+ background: var(--accent);
90
+ color: #000;
91
+ border: none;
92
+ border-radius: var(--r);
93
+ padding: 0.85rem 1.5rem;
94
+ font-size: 1rem;
95
+ font-weight: 600;
96
+ cursor: pointer;
97
+ transition: opacity .18s, transform .1s;
98
+ }
99
+ .btn-primary.inline { width: auto; white-space: nowrap; }
100
+ .btn-primary:hover:not(:disabled) { opacity: .88; transform: translateY(-1px); }
101
+ .btn-primary:active:not(:disabled) { transform: translateY(0); }
102
+ .btn-primary:disabled { opacity: .4; cursor: not-allowed; }
103
+
104
+ .btn-ghost {
105
+ display: block;
106
+ width: 100%;
107
+ background: none;
108
+ color: var(--muted);
109
+ border: 1px solid var(--border);
110
+ border-radius: var(--r);
111
+ padding: 0.75rem 1.5rem;
112
+ font-size: 0.9rem;
113
+ cursor: pointer;
114
+ text-align: center;
115
+ text-decoration: none;
116
+ margin-top: 0.75rem;
117
+ transition: color .18s, border-color .18s;
118
+ }
119
+ .btn-ghost:hover { color: var(--text); border-color: var(--muted); }
120
+
121
+ /* ── Game header ─────────────────────────────────────────────── */
122
+ .game-header {
123
+ position: sticky;
124
+ top: 0;
125
+ z-index: 10;
126
+ width: 100%;
127
+ display: grid;
128
+ grid-template-columns: 1fr 3fr 1fr;
129
+ align-items: center;
130
+ gap: 1rem;
131
+ padding: 0.85rem 1.75rem;
132
+ background: var(--surface);
133
+ border-bottom: 1px solid var(--border);
134
+ }
135
+
136
+ .header-stat { font-size: 0.88rem; color: var(--muted); font-weight: 500; }
137
+ .header-stat.right { text-align: right; }
138
+
139
+ .progress-bar {
140
+ height: 5px;
141
+ background: var(--surface2);
142
+ border-radius: 3px;
143
+ overflow: hidden;
144
+ }
145
+ #progress-fill {
146
+ height: 100%;
147
+ background: var(--accent);
148
+ width: 0%;
149
+ transition: width .45s ease;
150
+ }
151
+
152
+ /* ── Game main ───────────────────────────────────────────────── */
153
+ .game-main {
154
+ width: 100%;
155
+ max-width: 980px;
156
+ padding: 1.5rem 1.75rem 2rem;
157
+ flex: 1;
158
+ display: flex;
159
+ flex-direction: column;
160
+ align-items: center;
161
+ gap: 1.4rem;
162
+ }
163
+
164
+ .prompt { font-size: 1.35rem; font-weight: 600; text-align: center; }
165
+ .prompt em { color: var(--accent); font-style: normal; }
166
+
167
+ /* ── Arena ───────────────────────────────────────────────────── */
168
+ .arena {
169
+ width: 100%;
170
+ display: flex;
171
+ align-items: stretch;
172
+ gap: 0.75rem;
173
+ }
174
+
175
+ .img-panel {
176
+ flex: 1;
177
+ position: relative;
178
+ border: 2px solid var(--border);
179
+ border-radius: var(--r);
180
+ overflow: hidden;
181
+ cursor: pointer;
182
+ background: #000;
183
+ transition: border-color .2s ease, box-shadow .2s ease, transform .12s ease;
184
+ user-select: none;
185
+ }
186
+ .img-panel:not(.disabled):hover {
187
+ border-color: var(--accent);
188
+ box-shadow: 0 0 0 3px rgba(88,166,255,.14);
189
+ transform: scale(1.012);
190
+ }
191
+ .img-panel:not(.disabled):active { transform: scale(.988); }
192
+ .img-panel.disabled { cursor: default; }
193
+
194
+ .img-panel img {
195
+ width: 100%;
196
+ display: block;
197
+ object-fit: contain;
198
+ max-height: 46vh;
199
+ min-height: 180px;
200
+ pointer-events: none;
201
+ }
202
+
203
+ .panel-label {
204
+ position: absolute;
205
+ top: 0.55rem;
206
+ left: 0.55rem;
207
+ background: rgba(0,0,0,.72);
208
+ color: #fff;
209
+ font-weight: 700;
210
+ font-size: 0.85rem;
211
+ padding: 0.15rem 0.45rem;
212
+ border-radius: 5px;
213
+ z-index: 2;
214
+ letter-spacing: .06em;
215
+ }
216
+
217
+ /* Feedback state on panels */
218
+ .img-panel.correct {
219
+ border-color: var(--green) !important;
220
+ box-shadow: 0 0 0 3px rgba(63,185,80,.18), 0 0 28px rgba(63,185,80,.12) !important;
221
+ }
222
+ .img-panel.incorrect {
223
+ border-color: var(--red) !important;
224
+ box-shadow: 0 0 0 3px rgba(248,81,73,.18), 0 0 28px rgba(248,81,73,.1) !important;
225
+ }
226
+
227
+ /* ── VS divider ──────────────────────────────────────────────── */
228
+ .vs-divider {
229
+ display: flex;
230
+ flex-direction: column;
231
+ align-items: center;
232
+ justify-content: center;
233
+ flex-shrink: 0;
234
+ padding: 0 0.25rem;
235
+ gap: 0.7rem;
236
+ }
237
+ .vs-divider::before, .vs-divider::after {
238
+ content: '';
239
+ flex: 1;
240
+ width: 1px;
241
+ background: var(--border);
242
+ }
243
+ .vs-divider span {
244
+ font-size: 0.75rem;
245
+ font-weight: 700;
246
+ color: var(--muted);
247
+ letter-spacing: .1em;
248
+ }
249
+
250
+ /* ── Trial feedback bar ──────────────────────────────────────── */
251
+ .feedback-bar {
252
+ font-size: 1.05rem;
253
+ font-weight: 600;
254
+ padding: 0.7rem 1.5rem;
255
+ border-radius: var(--r);
256
+ text-align: center;
257
+ min-height: 3.2rem;
258
+ display: flex;
259
+ align-items: center;
260
+ justify-content: center;
261
+ width: 100%;
262
+ max-width: 600px;
263
+ opacity: 0;
264
+ transition: opacity .25s;
265
+ }
266
+ .feedback-bar.correct-fb {
267
+ opacity: 1;
268
+ background: rgba(63,185,80,.12);
269
+ color: var(--green);
270
+ border: 1px solid rgba(63,185,80,.28);
271
+ }
272
+ .feedback-bar.incorrect-fb {
273
+ opacity: 1;
274
+ background: rgba(248,81,73,.1);
275
+ color: var(--red);
276
+ border: 1px solid rgba(248,81,73,.22);
277
+ }
278
+
279
+ /* ── Name entry ──────────────────────────────────────────────── */
280
+ .final-score { text-align: center; margin: 0.75rem 0 1.5rem; }
281
+ .score-big { font-size: 3.6rem; font-weight: 700; color: var(--accent); line-height: 1; }
282
+ .score-sub { font-size: 1rem; color: var(--muted); margin-top: 0.3rem; }
283
+
284
+ .enter-name-label { margin-bottom: 0.75rem; }
285
+
286
+ .input-row { display: flex; gap: 0.65rem; }
287
+ .text-input {
288
+ flex: 1;
289
+ background: var(--surface2);
290
+ border: 1px solid var(--border);
291
+ color: var(--text);
292
+ padding: 0.75rem 1rem;
293
+ border-radius: var(--r);
294
+ font-size: 1rem;
295
+ font-family: var(--font);
296
+ outline: none;
297
+ transition: border-color .18s;
298
+ min-width: 0;
299
+ }
300
+ .text-input:focus { border-color: var(--accent); }
301
+ .text-input.error { border-color: var(--red); }
302
+
303
+ /* ── Results ─────────────────────────────────────────────────── */
304
+ .results-wrap {
305
+ width: min(680px, 100%);
306
+ padding: 0 0 2rem;
307
+ }
308
+
309
+ .your-result {
310
+ background: var(--surface);
311
+ border: 1px solid var(--accent);
312
+ border-radius: var(--r);
313
+ padding: 0.9rem 1.2rem;
314
+ margin-bottom: 1.4rem;
315
+ font-size: 0.97rem;
316
+ color: var(--text);
317
+ }
318
+
319
+ .table-wrap {
320
+ border: 1px solid var(--border);
321
+ border-radius: var(--r);
322
+ overflow-x: auto;
323
+ margin-bottom: 1.5rem;
324
+ }
325
+ .lb-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
326
+ .lb-table th {
327
+ background: var(--surface);
328
+ padding: 0.7rem 1rem;
329
+ text-align: left;
330
+ color: var(--muted);
331
+ font-weight: 600;
332
+ font-size: 0.78rem;
333
+ text-transform: uppercase;
334
+ letter-spacing: .06em;
335
+ border-bottom: 1px solid var(--border);
336
+ }
337
+ .lb-table td { padding: 0.65rem 1rem; border-bottom: 1px solid var(--border); }
338
+ .lb-table tr:last-child td { border-bottom: none; }
339
+ .lb-table tr.highlight td { background: rgba(88,166,255,.08); font-weight: 600; }
340
+ .lb-table tr.highlight td:first-child { color: var(--accent); }
341
+
342
+ .results-actions { display: flex; gap: 0.75rem; }
343
+ .results-actions .btn-primary,
344
+ .results-actions .btn-ghost { flex: 1; margin: 0; }
345
+
346
+ /* ── Responsive ──────────────────────────────────────────────── */
347
+ @media (max-width: 580px) {
348
+ .game-header {
349
+ grid-template-columns: 1fr 1fr;
350
+ grid-template-rows: auto auto;
351
+ padding: 0.7rem 1rem;
352
+ }
353
+ .progress-bar { grid-column: 1 / -1; grid-row: 2; }
354
+
355
+ .arena { flex-direction: column; }
356
+ .vs-divider { flex-direction: row; padding: 0; gap: 0.6rem; }
357
+ .vs-divider::before, .vs-divider::after { flex: 1; height: 1px; width: auto; }
358
+
359
+ .img-panel img { max-height: 38vh; }
360
+ .game-main { padding: 1rem; gap: 1rem; }
361
+ h1 { font-size: 1.9rem; }
362
+ .card { padding: 1.75rem 1.4rem; }
363
+ .input-row { flex-direction: column; }
364
+ .btn-primary.inline { width: 100%; }
365
+ .results-actions { flex-direction: column; }
366
+ }
server.js ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { randomUUID } = require('crypto');
5
+
6
+ const app = express();
7
+ const PORT = process.env.PORT || 3000;
8
+ const DATA_FILE = path.join(__dirname, 'data', 'results.json');
9
+ const IMAGES_DIR = path.join(__dirname, 'images');
10
+
11
+ app.use(express.json());
12
+ app.use(express.static(path.join(__dirname, 'public')));
13
+ app.use('/images', express.static(IMAGES_DIR));
14
+
15
+ // Auto-create required directories and data file on first run
16
+ ['data', 'images/real', 'images/fake'].forEach(dir => {
17
+ const p = path.join(__dirname, dir);
18
+ if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
19
+ });
20
+ if (!fs.existsSync(DATA_FILE)) {
21
+ fs.writeFileSync(DATA_FILE, JSON.stringify({ sessions: [], trials: [] }, null, 2));
22
+ }
23
+
24
+ const IMAGE_EXT = /\.(jpe?g|png|tiff?|webp|bmp|gif)$/i;
25
+
26
+ function readData() {
27
+ return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
28
+ }
29
+
30
+ function writeData(data) {
31
+ fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
32
+ }
33
+
34
+ // GET /api/images — list real and fake images
35
+ app.get('/api/images', (req, res) => {
36
+ try {
37
+ const list = (subdir) =>
38
+ fs.readdirSync(path.join(IMAGES_DIR, subdir))
39
+ .filter(f => IMAGE_EXT.test(f))
40
+ .map(f => ({ id: f, src: `/images/${subdir}/${f}`, type: subdir }));
41
+
42
+ res.json({ real: list('real'), fake: list('fake') });
43
+ } catch (err) {
44
+ res.status(500).json({ error: err.message });
45
+ }
46
+ });
47
+
48
+ // POST /api/submit — save a completed session + all trial decisions
49
+ app.post('/api/submit', (req, res) => {
50
+ const { playerName, trials } = req.body;
51
+ if (!playerName || !Array.isArray(trials) || trials.length === 0) {
52
+ return res.status(400).json({ error: 'Invalid payload' });
53
+ }
54
+
55
+ const sessionId = randomUUID();
56
+ const timestamp = new Date().toISOString();
57
+ const score = trials.filter(t => t.correct).length;
58
+ const total = trials.length;
59
+
60
+ const data = readData();
61
+
62
+ data.sessions.push({
63
+ session_id: sessionId,
64
+ player_name: playerName,
65
+ timestamp,
66
+ score,
67
+ total,
68
+ percentage: Math.round((score / total) * 100)
69
+ });
70
+
71
+ trials.forEach(trial => {
72
+ data.trials.push({ session_id: sessionId, player_name: playerName, timestamp, ...trial });
73
+ });
74
+
75
+ writeData(data);
76
+ res.json({ sessionId, score, total });
77
+ });
78
+
79
+ // GET /api/leaderboard — top 50 sessions by accuracy, then recency
80
+ app.get('/api/leaderboard', (req, res) => {
81
+ const { sessions } = readData();
82
+ const sorted = [...sessions]
83
+ .sort((a, b) => b.percentage - a.percentage || new Date(b.timestamp) - new Date(a.timestamp))
84
+ .slice(0, 50);
85
+ res.json(sorted);
86
+ });
87
+
88
+ // GET /api/export — download full raw data for offline 2AFC analysis
89
+ app.get('/api/export', (req, res) => {
90
+ res.setHeader('Content-Disposition', 'attachment; filename="2afc_data.json"');
91
+ res.sendFile(DATA_FILE);
92
+ });
93
+
94
+ app.listen(PORT, () => {
95
+ console.log(`Microtubule Challenge → http://localhost:${PORT}`);
96
+ console.log(` Drop images into: images/real/ and images/fake/`);
97
+ console.log(` Export 2AFC data: http://localhost:${PORT}/api/export`);
98
+ });