This commit is contained in:
2025-09-09 14:50:09 +08:00
commit 6880adae96
13 changed files with 826 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

4
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings" defaultProject="true" />
</project>

46
pom.xml Normal file
View File

@@ -0,0 +1,46 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.9</version>
<relativePath/>
</parent>
<groupId>org.example</groupId>
<artifactId>quick_response</artifactId>
<version>1.0-SNAPSHOT</version>
<name>quick_response</name>
<description>Quick response buzzer app</description>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,13 @@
package org.example;
/**
* Hello world!
*
*/
public class App
{
public static void main( String[] args )
{
System.out.println( "Hello World!" );
}
}

View File

@@ -0,0 +1,11 @@
package org.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class QuickResponseApplication {
public static void main(String[] args) {
SpringApplication.run(QuickResponseApplication.class, args);
}
}

View File

@@ -0,0 +1,25 @@
package org.example.config;
import org.example.ws.QuickResponseWebSocketHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final QuickResponseWebSocketHandler handler;
@Autowired
public WebSocketConfig(QuickResponseWebSocketHandler handler) {
this.handler = handler;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(handler, "/ws/quick").setAllowedOrigins("*");
}
}

View File

@@ -0,0 +1,40 @@
package org.example.model;
public class Player {
private String id;
private String name;
private boolean host;
public Player() {
}
public Player(String id, String name, boolean host) {
this.id = id;
this.name = name;
this.host = host;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public boolean isHost() {
return host;
}
public void setHost(boolean host) {
this.host = host;
}
}

View File

@@ -0,0 +1,204 @@
package org.example.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.example.model.Player;
import org.springframework.stereotype.Service;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.*;
@Service
public class GameService {
private final ObjectMapper objectMapper = new ObjectMapper();
private final Set<WebSocketSession> sessions = ConcurrentHashMap.newKeySet();
private final Map<String, Player> sessionPlayers = new ConcurrentHashMap<>();
private final List<Map<String, Object>> violations = Collections.synchronizedList(new ArrayList<>());
private final List<Map<String, Object>> ranking = Collections.synchronizedList(new ArrayList<>());
// 新增:本轮已违规(或已点击)的选手集合,禁止进入排名
private final Set<String> disqualified = ConcurrentHashMap.newKeySet();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private volatile boolean countdownActive = false;
private volatile boolean acceptingBuzz = false;
// 倒计时结束的绝对时间,用于计算违规“提前几毫秒”
private volatile long countdownEndAtMillis = 0L;
public void registerSession(WebSocketSession session) {
sessions.add(session);
}
public void removeSession(WebSocketSession session) {
sessions.remove(session);
sessionPlayers.remove(session.getId());
}
public void login(WebSocketSession session, String id, String name, boolean host) throws IOException {
Player p = new Player(id, name, host);
sessionPlayers.put(session.getId(), p);
Map<String, Object> ack = Map.of(
"type", "login_ack",
"payload", Map.of("host", host, "name", name, "id", id));
sendTo(session, ack);
// 同步当前状态
sendViolations();
sendRanking();
}
public void startCountdown(WebSocketSession session, int seconds) throws IOException {
Player p = sessionPlayers.get(session.getId());
if (p == null || !p.isHost()) {
sendError(session, "只有主持人可以开始倒计时");
return;
}
if (countdownActive) {
sendError(session, "倒计时已在进行");
return;
}
// 若上一轮仍开放,先清理状态但不通知错误
if (acceptingBuzz) {
resetInternal(false);
}
// 新一轮开始前清空违规名单
disqualified.clear();
countdownActive = true;
acceptingBuzz = false;
countdownEndAtMillis = System.currentTimeMillis() + seconds * 1000L;
for (int i = seconds; i >= 1; i--) {
final int val = i;
scheduler.schedule(() -> sendCountdown(val), seconds - i, TimeUnit.SECONDS);
}
scheduler.schedule(() -> {
countdownActive = false;
acceptingBuzz = true;
broadcastToAllExceptHost(Map.of("type", "countdown_end"));
}, seconds, TimeUnit.SECONDS);
}
private void sendCountdown(int value) {
broadcastToAllExceptHost(Map.of(
"type", "countdown",
"payload", Map.of("value", value)));
}
public void buzz(WebSocketSession session) throws IOException {
Player p = sessionPlayers.get(session.getId());
if (p == null) {
sendError(session, "未登录");
return;
}
if (p.isHost()) {
sendError(session, "主持人不能抢答");
return;
}
if (countdownActive) {
// 原子加锁:同一轮仅允许第一次点击成功(记录为违规)
boolean first = disqualified.add(p.getId());
if (!first) { // 已点击过(可能是并发双击或已违规/已排名)
sendError(session, "本轮已抢答,不能重复抢答");
return;
}
long now = System.currentTimeMillis();
long earlyMs = Math.max(0L, countdownEndAtMillis - now);
Map<String, Object> v = new HashMap<>();
v.put("id", p.getId());
v.put("name", p.getName());
v.put("time", now);
v.put("earlyMs", earlyMs);
violations.add(v);
sendViolations();
return;
}
if (acceptingBuzz) {
// 原子加锁:进入排名前先锁定,防止并发双击进两次
boolean first = disqualified.add(p.getId());
if (!first) {
sendError(session, "本轮已抢答,不能重复抢答");
return;
}
synchronized (ranking) {
boolean already = ranking.stream().anyMatch(e -> Objects.equals(e.get("id"), p.getId()));
if (!already) {
Map<String, Object> r = new HashMap<>();
r.put("id", p.getId());
r.put("name", p.getName());
r.put("time", System.currentTimeMillis());
r.put("rank", ranking.size() + 1);
ranking.add(r);
sendRanking();
}
}
return;
}
sendError(session, "未开始抢答");
}
public void reset(WebSocketSession session) throws IOException {
Player p = sessionPlayers.get(session.getId());
if (p == null || !p.isHost()) {
sendError(session, "只有主持人可以重置");
return;
}
resetInternal(true);
}
private void resetInternal(boolean notify) {
countdownActive = false;
acceptingBuzz = false;
countdownEndAtMillis = 0L;
violations.clear();
ranking.clear();
disqualified.clear();
broadcast(Map.of("type", "state_reset"));
sendViolations();
sendRanking();
}
private void sendViolations() {
broadcast(Map.of(
"type", "violation_update",
"payload", Map.of("list", new ArrayList<>(violations))));
}
private void sendRanking() {
broadcast(Map.of(
"type", "ranking_update",
"payload", Map.of("list", new ArrayList<>(ranking))));
}
private void broadcast(Map<String, Object> msg) {
for (WebSocketSession s : sessions) {
safeSend(s, msg);
}
}
private void broadcastToAllExceptHost(Map<String, Object> msg) {
for (WebSocketSession s : sessions) {
Player sp = sessionPlayers.get(s.getId());
if (sp == null || !sp.isHost()) {
safeSend(s, msg);
}
}
}
private void safeSend(WebSocketSession s, Map<String, Object> msg) {
try {
s.sendMessage(new TextMessage(objectMapper.writeValueAsString(msg)));
} catch (Exception ignored) {
}
}
private void sendTo(WebSocketSession s, Map<String, Object> msg) throws IOException {
s.sendMessage(new TextMessage(objectMapper.writeValueAsString(msg)));
}
private void sendError(WebSocketSession s, String error) throws IOException {
sendTo(s, Map.of("type", "error", "payload", Map.of("message", error)));
}
}

View File

@@ -0,0 +1,61 @@
package org.example.ws;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.example.service.GameService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
@Component
public class QuickResponseWebSocketHandler extends TextWebSocketHandler {
private final GameService gameService;
private final ObjectMapper objectMapper;
@Autowired
public QuickResponseWebSocketHandler(GameService gameService, ObjectMapper objectMapper) {
this.gameService = gameService;
this.objectMapper = objectMapper;
}
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
gameService.registerSession(session);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
JsonNode root = objectMapper.readTree(message.getPayload());
String type = root.path("type").asText("");
JsonNode payload = root.path("payload");
switch (type) {
case "login":
String id = payload.path("id").asText("");
String name = payload.path("name").asText("");
boolean host = payload.path("host").asBoolean(false);
gameService.login(session, id, name, host);
break;
case "start":
gameService.startCountdown(session, 3); // 固定3秒倒计时
break;
case "buzz":
gameService.buzz(session);
break;
case "reset":
gameService.reset(session);
break;
default:
// ignore or send error
break;
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
gameService.removeSession(session);
}
}

View File

@@ -0,0 +1 @@
server.port=8089

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>抢答小程序 - 登录</title>
<style>
:root {
--bg: #f6f8fa;
--fg: #24292f;
--muted: #57606a;
--primary: #0969da;
--border: #eaeef2;
}
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'PingFang SC', 'Microsoft Yahei', sans-serif;
margin: 0;
padding: 24px;
background: var(--bg);
color: var(--fg);
}
.card {
background: #fff;
border-radius: 12px;
padding: 20px;
margin: 12px auto;
max-width: 720px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06);
}
h1 {
margin: 0 0 8px;
font-size: 22px;
}
.row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
input[type=text] {
padding: 10px 12px;
border: 1px solid #d0d7de;
border-radius: 8px;
outline: none;
min-width: 240px;
}
button {
background: var(--primary);
color: #fff;
border: none;
border-radius: 8px;
padding: 10px 16px;
cursor: pointer;
font-weight: 600;
}
.muted {
color: var(--muted);
font-size: 12px;
}
</style>
</head>
<body>
<div class="card">
<h1>抢答小程序 - 登录</h1>
<div class="row">
<input id="name" type="text" placeholder="请输入昵称(输入 admin 将作为主持人)" />
<button id="loginBtn">进入</button>
<span id="status" class="muted"></span>
</div>
</div>
<script>
(function () {
const nameEl = document.getElementById('name');
const loginBtn = document.getElementById('loginBtn');
const statusEl = document.getElementById('status');
function randomId() {
return 'U' + Math.random().toString(36).slice(2, 8).toUpperCase();
}
loginBtn.onclick = () => {
const name = nameEl.value.trim();
if (!name) { alert('请输入昵称'); return; }
const isHost = name.toLowerCase() === 'admin';
const id = isHost ? 'admin' : randomId();
// 保存到本地并跳转
localStorage.setItem('qr_user', JSON.stringify({ id, name, host: isHost }));
location.href = 'play.html';
};
// 简单显示连接状态提示(无实际连接)
statusEl.textContent = '请输入昵称后点击进入';
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,264 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>抢答小程序 - 抢答页面</title>
<style>
:root {
--bg: #f6f8fa;
--fg: #24292f;
--muted: #57606a;
--primary: #0969da;
--primary-soft: #e7f5ff;
--success: #1a7f37;
--danger: #d1242f;
--border: #eaeef2;
}
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'PingFang SC', 'Microsoft Yahei', sans-serif;
margin: 0;
padding: 24px;
background: var(--bg);
color: var(--fg);
}
.card {
background: #fff;
border-radius: 12px;
padding: 20px;
margin: 12px auto;
max-width: 900px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06);
}
h1 {
margin: 0 0 8px;
font-size: 22px;
}
.row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
button {
background: var(--primary);
color: #fff;
border: none;
border-radius: 8px;
padding: 10px 16px;
cursor: pointer;
font-weight: 600;
}
button:disabled {
background: #94b7ee;
cursor: not-allowed;
}
.danger {
background: var(--danger);
}
.success {
background: var(--success);
}
.muted {
color: var(--muted);
font-size: 12px;
}
.list {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
margin-top: 8px;
}
.item {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
justify-content: space-between;
}
.countdown {
font-size: 48px;
text-align: center;
font-weight: 800;
margin: 10px 0;
min-height: 58px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.tag {
padding: 2px 8px;
border-radius: 12px;
background: var(--primary-soft);
color: var(--primary);
font-size: 12px;
}
</style>
</head>
<body>
<div class="card">
<h1>抢答小程序</h1>
<div class="row">
<span id="userInfo" class="muted"></span>
<button id="backBtn">退出登录</button>
<span id="status" class="muted"></span>
</div>
</div>
<div class="card" id="hostPanel" style="display:none;">
<h3>主持人面板 <span class="tag">仅主持人可见</span></h3>
<div class="row">
<button id="startBtn" class="success">开始3秒倒计时</button>
<button id="resetBtn" class="danger">重置</button>
</div>
</div>
<div class="card" id="playerPanel" style="display:none;">
<div class="countdown" id="countdown"></div>
<div style="text-align:center; margin-top:12px;">
<button id="buzzBtn" style="font-size:20px; padding: 14px 28px;" disabled>抢答</button>
</div>
</div>
<div class="card grid">
<div>
<h3>抢答排名</h3>
<div id="ranking" class="list"></div>
</div>
<div>
<h3>违规抢答</h3>
<div id="violations" class="list"></div>
</div>
</div>
<script>
(function () {
const user = JSON.parse(localStorage.getItem('qr_user') || 'null');
if (!user) { location.href = 'index.html'; return; }
const userInfoEl = document.getElementById('userInfo');
const backBtn = document.getElementById('backBtn');
const startBtn = document.getElementById('startBtn');
const resetBtn = document.getElementById('resetBtn');
const buzzBtn = document.getElementById('buzzBtn');
const statusEl = document.getElementById('status');
const hostPanel = document.getElementById('hostPanel');
const playerPanel = document.getElementById('playerPanel');
const cdEl = document.getElementById('countdown');
const rankingEl = document.getElementById('ranking');
const violationsEl = document.getElementById('violations');
userInfoEl.textContent = `当前用户:${user.name}${user.host ? '(主持人)' : ''}`;
backBtn.onclick = () => { localStorage.removeItem('qr_user'); location.href = 'index.html'; };
let ws = null;
let isHost = false;
// 本地本轮是否已点击抢答(无论违规或合规)
let hasBuzzedThisRound = false;
// 是否处于倒计时中,用于识别新一轮开始
let isCountdownActive = false;
function connect() {
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/quick';
ws = new WebSocket(url);
ws.onopen = () => {
statusEl.textContent = '已连接';
ws.send(JSON.stringify({ type: 'login', payload: user }));
};
ws.onclose = () => { statusEl.textContent = '连接已断开'; };
ws.onerror = () => { statusEl.textContent = '连接错误'; };
ws.onmessage = (evt) => {
let msg = {};
try { msg = JSON.parse(evt.data || '{}'); } catch (e) { return; }
const type = msg.type;
const payload = msg.payload || {};
if (type === 'login_ack') {
isHost = !!payload.host;
hostPanel.style.display = isHost ? 'block' : 'none';
playerPanel.style.display = isHost ? 'none' : 'block';
if (!isHost) { hasBuzzedThisRound = false; isCountdownActive = false; buzzBtn.disabled = true; }
} else if (type === 'countdown') {
if (!isHost) {
// 新一轮第一次收到倒计时,重置本地状态
if (!isCountdownActive) { hasBuzzedThisRound = false; }
isCountdownActive = true;
cdEl.textContent = payload.value;
buzzBtn.disabled = hasBuzzedThisRound; // 未点击则允许,点击过则置灰
}
} else if (type === 'countdown_end') {
if (!isHost) {
isCountdownActive = false;
cdEl.textContent = '开始!';
buzzBtn.disabled = hasBuzzedThisRound; // 未点击则可继续尝试进入排名
setTimeout(() => { cdEl.textContent = ''; }, 800);
}
} else if (type === 'violation_update') {
const list = payload.list || [];
violationsEl.innerHTML = list.map((v, idx) => {
const earlyMs = typeof v.earlyMs === 'number' ? v.earlyMs : null;
const earlyText = earlyMs != null
? `提前 ${Math.floor(earlyMs / 1000)}${earlyMs % 1000}毫秒`
: '违规(提前时间未知)';
return `<div class="item"><span>${idx + 1}. ${v.name} </span><span class="muted">${earlyText}</span></div>`;
}).join('');
// 若自己出现在违规列表中,则锁定本轮
if (list.some(v => v.id === user.id)) { hasBuzzedThisRound = true; buzzBtn.disabled = true; }
} else if (type === 'ranking_update') {
const list = payload.list || [];
rankingEl.innerHTML = list.map((r) => `<div class="item"><span>#${r.rank} ${r.name}</span><span class="muted">${new Date(r.time).toLocaleTimeString()}</span></div>`).join('');
// 若自己已进入排名,则锁定本轮
if (list.some(r => r.id === user.id)) { hasBuzzedThisRound = true; buzzBtn.disabled = true; }
} else if (type === 'state_reset') {
cdEl.textContent = '';
hasBuzzedThisRound = false;
isCountdownActive = false;
buzzBtn.disabled = true;
rankingEl.innerHTML = '';
violationsEl.innerHTML = '';
} else if (type === 'error') {
const msgText = payload.message || '发生错误';
alert(msgText);
// 若服务端提示已抢答/已违规,也将前端本轮锁定
if (/已抢答|已违规/.test(msgText)) { hasBuzzedThisRound = true; buzzBtn.disabled = true; }
}
};
}
startBtn.onclick = () => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'start' })); };
resetBtn.onclick = () => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'reset' })); };
buzzBtn.onclick = () => {
if (ws && ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'buzz' }));
// 本地先行锁定,避免并发多次发送
hasBuzzedThisRound = true;
buzzBtn.disabled = true;
}
};
connect();
})();
</script>
</body>
</html>