Spaces:
Running
Running
Commit ·
944af38
1
Parent(s): 6d65c46
add initial game implementation with HTML, CSS, and JavaScript
Browse files- .idea/.gitignore +10 -0
- .idea/DiffMT.iml +8 -0
- .idea/inspectionProfiles/Project_Default.xml +19 -0
- .idea/inspectionProfiles/profiles_settings.xml +6 -0
- .idea/misc.xml +4 -0
- .idea/modules.xml +8 -0
- .idea/vcs.xml +7 -0
- package.json +12 -0
- public/game.js +289 -0
- public/index.html +93 -0
- public/style.css +366 -0
- server.js +98 -0
.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 |
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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} / ${state.pairs.length}</strong> (${pct}% accuracy)`
|
| 247 |
+
: `Your score: <strong>${state.score} / ${state.pairs.length}</strong> (${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 · <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 |
+
});
|