diff --git a/arena.go b/arena.go index 20d3b17..7394fac 100644 --- a/arena.go +++ b/arena.go @@ -75,6 +75,7 @@ type Arena struct { audienceDisplayNotifier *Notifier playSoundNotifier *Notifier audienceDisplayScreen string + allianceStationDisplays map[string]string lastMatchState int lastMatchTimeSec float64 savedMatch *Match @@ -118,6 +119,7 @@ func (arena *Arena) Setup() { arena.audienceDisplayScreen = "blank" arena.savedMatch = &Match{} arena.savedMatchResult = &MatchResult{} + arena.allianceStationDisplays = make(map[string]string) } // Loads a team into an alliance station, cleaning up the previous team there if there is one. diff --git a/displays.go b/displays.go index 07a8dd8..c992e33 100644 --- a/displays.go +++ b/displays.go @@ -762,3 +762,159 @@ func RefereeDisplayWebsocketHandler(w http.ResponseWriter, r *http.Request) { } } } + +// Renders the team number and status display shown above each alliance station. +func AllianceStationDisplayHandler(w http.ResponseWriter, r *http.Request) { + template := template.New("").Funcs(templateHelpers) + _, err := template.ParseFiles("templates/alliance_station_display.html") + if err != nil { + handleWebErr(w, err) + return + } + + data := struct { + *EventSettings + }{eventSettings} + err = template.ExecuteTemplate(w, "alliance_station_display.html", data) + if err != nil { + handleWebErr(w, err) + return + } +} + +// The websocket endpoint for the alliance station display client to receive status updates. +func AllianceStationDisplayWebsocketHandler(w http.ResponseWriter, r *http.Request) { + websocket, err := NewWebsocket(w, r) + if err != nil { + handleWebErr(w, err) + return + } + defer websocket.Close() + + displayId := r.URL.Query()["displayId"][0] + station, ok := mainArena.allianceStationDisplays[displayId] + if !ok { + station = "" + mainArena.allianceStationDisplays[displayId] = station + } + defer delete(mainArena.allianceStationDisplays, displayId) + + matchLoadTeamsListener := mainArena.matchLoadTeamsNotifier.Listen() + defer close(matchLoadTeamsListener) + matchTimeListener := mainArena.matchTimeNotifier.Listen() + defer close(matchTimeListener) + realtimeScoreListener := mainArena.realtimeScoreNotifier.Listen() + defer close(realtimeScoreListener) + + // Send the various notifications immediately upon connection. + var data interface{} + err = websocket.Write("matchTiming", mainArena.matchTiming) + if err != nil { + log.Printf("Websocket error: %s", err) + return + } + err = websocket.Write("matchTime", MatchTimeMessage{mainArena.MatchState, int(mainArena.lastMatchTimeSec)}) + if err != nil { + log.Printf("Websocket error: %s", err) + return + } + data = struct { + AllianceStation string + Teams map[string]*Team + }{station, map[string]*Team{"R1": mainArena.AllianceStations["R1"].team, + "R2": mainArena.AllianceStations["R2"].team, "R3": mainArena.AllianceStations["R3"].team, + "B1": mainArena.AllianceStations["B1"].team, "B2": mainArena.AllianceStations["B2"].team, + "B3": mainArena.AllianceStations["B3"].team}} + err = websocket.Write("setMatch", data) + if err != nil { + log.Printf("Websocket error: %s", err) + return + } + data = struct { + RedScore int + RedCycle Cycle + BlueScore int + BlueCycle Cycle + }{mainArena.redRealtimeScore.Score(mainArena.blueRealtimeScore.Fouls), + mainArena.redRealtimeScore.CurrentCycle, + mainArena.blueRealtimeScore.Score(mainArena.redRealtimeScore.Fouls), + mainArena.blueRealtimeScore.CurrentCycle} + err = websocket.Write("realtimeScore", data) + if err != nil { + log.Printf("Websocket error: %s", err) + return + } + + // Spin off a goroutine to listen for notifications and pass them on through the websocket. + go func() { + for { + var messageType string + var message interface{} + select { + case _, ok := <-matchLoadTeamsListener: + if !ok { + return + } + messageType = "setMatch" + station = mainArena.allianceStationDisplays[displayId] + message = struct { + AllianceStation string + Teams map[string]*Team + }{station, map[string]*Team{station: mainArena.AllianceStations["R1"].team, + "R2": mainArena.AllianceStations["R2"].team, "R3": mainArena.AllianceStations["R3"].team, + "B1": mainArena.AllianceStations["B1"].team, "B2": mainArena.AllianceStations["B2"].team, + "B3": mainArena.AllianceStations["B3"].team}} + case matchTimeSec, ok := <-matchTimeListener: + if !ok { + return + } + messageType = "matchTime" + message = MatchTimeMessage{mainArena.MatchState, matchTimeSec.(int)} + case _, ok := <-realtimeScoreListener: + if !ok { + return + } + messageType = "realtimeScore" + message = struct { + RedScore int + RedCycle Cycle + BlueScore int + BlueCycle Cycle + }{mainArena.redRealtimeScore.Score(mainArena.blueRealtimeScore.Fouls), + mainArena.redRealtimeScore.CurrentCycle, + mainArena.blueRealtimeScore.Score(mainArena.redRealtimeScore.Fouls), + mainArena.blueRealtimeScore.CurrentCycle} + } + err = websocket.Write(messageType, message) + if err != nil { + // The client has probably closed the connection; nothing to do here. + return + } + } + }() + + // Loop, waiting for commands and responding to them, until the client closes the connection. + for { + messageType, data, err := websocket.Read() + if err != nil { + if err == io.EOF { + // Client has closed the connection; nothing to do here. + return + } + log.Printf("Websocket error: %s", err) + return + } + + switch messageType { + case "setAllianceStation": + station, ok := data.(string) + if !ok { + websocket.WriteError(fmt.Sprintf("Failed to parse '%s' message.", messageType)) + continue + } + mainArena.allianceStationDisplays[displayId] = station + default: + websocket.WriteError(fmt.Sprintf("Invalid message type '%s'.", messageType)) + } + } +} diff --git a/setup_field.go b/setup_field.go new file mode 100644 index 0000000..5b8b525 --- /dev/null +++ b/setup_field.go @@ -0,0 +1,38 @@ +// Copyright 2014 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) +// +// Web routes for configuring the field components. + +package main + +import ( + "html/template" + "net/http" +) + +// Shows the field configuration page. +func FieldGetHandler(w http.ResponseWriter, r *http.Request) { + template, err := template.ParseFiles("templates/field.html", "templates/base.html") + if err != nil { + handleWebErr(w, err) + return + } + data := struct { + *EventSettings + AllianceStationDisplays map[string]string + }{eventSettings, mainArena.allianceStationDisplays} + err = template.ExecuteTemplate(w, "base", data) + if err != nil { + handleWebErr(w, err) + return + } +} + +// Updates the display-station mapping for a single display. +func FieldPostHandler(w http.ResponseWriter, r *http.Request) { + displayId := r.PostFormValue("displayId") + allianceStation := r.PostFormValue("allianceStation") + mainArena.allianceStationDisplays[displayId] = allianceStation + mainArena.matchLoadTeamsNotifier.Notify(nil) + http.Redirect(w, r, "/setup/field", 302) +} diff --git a/static/css/alliance_station_display.css b/static/css/alliance_station_display.css new file mode 100644 index 0000000..75702aa --- /dev/null +++ b/static/css/alliance_station_display.css @@ -0,0 +1,39 @@ +html { + cursor: none; + -webkit-user-select: none; + -moz-user-select: none; + overflow: hidden; +} +body { + background-color: #000; + font-family: "FuturaLTBold"; +} +#displayId { + color: #ff0; + font-size: 500px; +} +#teamId { + font-size: 500px; + line-height: 500px; + margin: 50px 0px; +} +#teamName { + font-family: "FuturaLT"; + font-size: 120px; +} +[data-alliance=R] { + color: #f00; +} +[data-alliance=B] { + color: #00f; +} +.match-info { + color: #fff; + font-size: 190px; +} +#redScore { + color: #f00; +} +#blueScore { + color: #00f; +} diff --git a/static/js/alliance_station_display.js b/static/js/alliance_station_display.js new file mode 100644 index 0000000..b3548d8 --- /dev/null +++ b/static/js/alliance_station_display.js @@ -0,0 +1,79 @@ +// Copyright 2014 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) +// +// Client-side methods for the alliance station display. + +// A unique id to differentiate this station's display from its peers. +var displayId; +var allianceStation = ""; +var websocket; + +var handleSetMatch = function(data) { + if (allianceStation != "" && data.AllianceStation == "") { + // The client knows better what display this should be; let the server know. + websocket.send("setAllianceStation", allianceStation); + } else if (allianceStation != data.AllianceStation) { + // The server knows better what display this should be; sync up. + allianceStation = data.AllianceStation; + } + + if (allianceStation != "") { + team = data.Teams[allianceStation]; + if (team == null) { + $("#teamId").text(""); + $("#teamName").text(""); + } else { + $("#teamId").attr("data-alliance", allianceStation[0]); + $("#teamName").attr("data-alliance", allianceStation[0]); + $("#teamId").text(data.Teams[allianceStation].Id); + $("#teamName").text(data.Teams[allianceStation].Nickname); + } + $("#displayIdRow").hide(); + $("#teamIdRow").show(); + $("#teamNameRow").show(); + } else { + // Show the display ID so that someone can assign it to a station from the configuration interface. + $("#teamId").text(""); + $("#teamName").text(""); + $("#displayIdRow").show(); + $("#teamIdRow").hide(); + $("#teamNameRow").hide(); + } +}; + +var handleMatchTime = function(data) { + translateMatchTime(data, function(matchState, matchStateText, countdownSec) { + var countdownString = String(countdownSec % 60); + if (countdownString.length == 1) { + countdownString = "0" + countdownString; + } + countdownString = Math.floor(countdownSec / 60) + ":" + countdownString; + $("#matchTime").text(countdownString); + + if (matchState == "PRE_MATCH" || matchState == "POST_MATCH") { + $("#teamNameRow").show(); + $("#matchInfoRow").hide(); + } else { + $("#teamNameRow").hide(); + $("#matchInfoRow").show(); + } + }); +}; + +var handleRealtimeScore = function(data) { + $("#redScore").text(data.RedScore); + $("#blueScore").text(data.BlueScore); +}; + +$(function() { + displayId = Math.floor(Math.random() * 10000); + $("#displayId").text(displayId); + + // Set up the websocket back to the server. + websocket = new CheesyWebsocket("/displays/alliance_station/websocket?displayId=" + displayId, { + setMatch: function(event) { handleSetMatch(event.data); }, + matchTiming: function(event) { handleMatchTiming(event.data); }, + matchTime: function(event) { handleMatchTime(event.data); }, + realtimeScore: function(event) { handleRealtimeScore(event.data); } + }); +}); diff --git a/static/js/audience_display.js b/static/js/audience_display.js index d5b7b5c..0433b01 100644 --- a/static/js/audience_display.js +++ b/static/js/audience_display.js @@ -3,6 +3,7 @@ // // Client-side methods for the audience display. +var websocket; var transitionMap; var currentScreen = "blank"; diff --git a/templates/alliance_station_display.html b/templates/alliance_station_display.html new file mode 100644 index 0000000..26aafe0 --- /dev/null +++ b/templates/alliance_station_display.html @@ -0,0 +1,32 @@ + + +
+