commit 6880adae9608afaae1feae2703365da6602b7de6 Author: gaohongjun <15666598496@139.com> Date: Tue Sep 9 14:50:09 2025 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ff6309 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..d843f34 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..00e9248 --- /dev/null +++ b/pom.xml @@ -0,0 +1,46 @@ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.9 + + + + org.example + quick_response + 1.0-SNAPSHOT + quick_response + Quick response buzzer app + + 17 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/src/main/java/org/example/App.java b/src/main/java/org/example/App.java new file mode 100644 index 0000000..5f21d2e --- /dev/null +++ b/src/main/java/org/example/App.java @@ -0,0 +1,13 @@ +package org.example; + +/** + * Hello world! + * + */ +public class App +{ + public static void main( String[] args ) + { + System.out.println( "Hello World!" ); + } +} diff --git a/src/main/java/org/example/QuickResponseApplication.java b/src/main/java/org/example/QuickResponseApplication.java new file mode 100644 index 0000000..45acd6d --- /dev/null +++ b/src/main/java/org/example/QuickResponseApplication.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/config/WebSocketConfig.java b/src/main/java/org/example/config/WebSocketConfig.java new file mode 100644 index 0000000..93ce43a --- /dev/null +++ b/src/main/java/org/example/config/WebSocketConfig.java @@ -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("*"); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/model/Player.java b/src/main/java/org/example/model/Player.java new file mode 100644 index 0000000..436dbea --- /dev/null +++ b/src/main/java/org/example/model/Player.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/org/example/service/GameService.java b/src/main/java/org/example/service/GameService.java new file mode 100644 index 0000000..0338800 --- /dev/null +++ b/src/main/java/org/example/service/GameService.java @@ -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 sessions = ConcurrentHashMap.newKeySet(); + private final Map sessionPlayers = new ConcurrentHashMap<>(); + private final List> violations = Collections.synchronizedList(new ArrayList<>()); + private final List> ranking = Collections.synchronizedList(new ArrayList<>()); + // 新增:本轮已违规(或已点击)的选手集合,禁止进入排名 + private final Set 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 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 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 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 msg) { + for (WebSocketSession s : sessions) { + safeSend(s, msg); + } + } + + private void broadcastToAllExceptHost(Map 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 msg) { + try { + s.sendMessage(new TextMessage(objectMapper.writeValueAsString(msg))); + } catch (Exception ignored) { + } + } + + private void sendTo(WebSocketSession s, Map 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))); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/ws/QuickResponseWebSocketHandler.java b/src/main/java/org/example/ws/QuickResponseWebSocketHandler.java new file mode 100644 index 0000000..a2d725e --- /dev/null +++ b/src/main/java/org/example/ws/QuickResponseWebSocketHandler.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..1463f2b --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1 @@ +server.port=8089 \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..07d63b5 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,111 @@ + + + + + + + 抢答小程序 - 登录 + + + + +
+

抢答小程序 - 登录

+
+ + + +
+
+ + + + + \ No newline at end of file diff --git a/src/main/resources/static/play.html b/src/main/resources/static/play.html new file mode 100644 index 0000000..bc71521 --- /dev/null +++ b/src/main/resources/static/play.html @@ -0,0 +1,264 @@ + + + + + + + 抢答小程序 - 抢答页面 + + + + +
+

抢答小程序

+
+ + + +
+
+ + + + + +
+
+

抢答排名

+
+
+
+

违规抢答

+
+
+
+ + + + + \ No newline at end of file