Initial commit

This commit is contained in:
Andrio Celos 2022-10-01 16:00:50 +10:00
commit 1f44ab6d56
45 changed files with 5268 additions and 0 deletions

225
.editorconfig Normal file
View File

@ -0,0 +1,225 @@
# Remove the line below if you want to inherit .editorconfig settings from higher directories
root = true
# C# files
[*.cs]
#### Core EditorConfig Options ####
# Indentation and spacing
indent_size = 4
indent_style = tab
tab_width = 4
# New line preferences
end_of_line = crlf
insert_final_newline = false
#### .NET Coding Conventions ####
# Organize usings
dotnet_separate_import_directive_groups = true
dotnet_sort_system_directives_first = true
file_header_template = unset
# this. and Me. preferences
dotnet_style_qualification_for_event = true:suggestion
dotnet_style_qualification_for_field = true
dotnet_style_qualification_for_method = true:suggestion
dotnet_style_qualification_for_property = true:suggestion
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
dotnet_style_predefined_type_for_member_access = true:suggestion
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:suggestion
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:suggestion
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members
# Expression-level preferences
dotnet_style_coalesce_expression = true
dotnet_style_collection_initializer = true
dotnet_style_explicit_tuple_names = true
dotnet_style_namespace_match_folder = true
dotnet_style_null_propagation = true
dotnet_style_object_initializer = true
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_prefer_compound_assignment = true
dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_return = true:suggestion
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed
dotnet_style_prefer_inferred_anonymous_type_member_names = true
dotnet_style_prefer_inferred_tuple_names = true
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
dotnet_style_prefer_simplified_boolean_expressions = true
dotnet_style_prefer_simplified_interpolation = true
# Field preferences
dotnet_style_readonly_field = true
# Parameter preferences
dotnet_code_quality_unused_parameters = non_public:warning
# Suppression preferences
dotnet_remove_unnecessary_suppression_exclusions = none
# New line preferences
dotnet_style_allow_multiple_blank_lines_experimental = false:suggestion
dotnet_style_allow_statement_immediately_after_block_experimental = true
#### C# Coding Conventions ####
# var preferences
csharp_style_var_elsewhere = true:suggestion
csharp_style_var_for_built_in_types = true
csharp_style_var_when_type_is_apparent = true:suggestion
# Expression-bodied members
csharp_style_expression_bodied_accessors = true:suggestion
csharp_style_expression_bodied_constructors = when_on_single_line:suggestion
csharp_style_expression_bodied_indexers = true:suggestion
csharp_style_expression_bodied_lambdas = true:suggestion
csharp_style_expression_bodied_local_functions = true:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_expression_bodied_operators = when_on_single_line:suggestion
csharp_style_expression_bodied_properties = true:suggestion
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true
csharp_style_pattern_matching_over_is_with_cast_check = true
csharp_style_prefer_extended_property_pattern = true
csharp_style_prefer_not_pattern = true
csharp_style_prefer_pattern_matching = true:suggestion
csharp_style_prefer_switch_expression = true
# Null-checking preferences
csharp_style_conditional_delegate_call = true
# Modifier preferences
csharp_prefer_static_local_function = true
csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async
# Code-block preferences
csharp_prefer_braces = when_multiline
csharp_prefer_simple_using_statement = true
csharp_style_namespace_declarations = file_scoped:suggestion
csharp_style_prefer_method_group_conversion = true:suggestion
csharp_style_prefer_top_level_statements = true
# Expression-level preferences
csharp_prefer_simple_default_expression = true
csharp_style_deconstructed_variable_declaration = true
csharp_style_implicit_object_creation_when_type_is_apparent = true
csharp_style_inlined_variable_declaration = true
csharp_style_prefer_index_operator = true
csharp_style_prefer_local_over_anonymous_function = true
csharp_style_prefer_null_check_over_type_check = true
csharp_style_prefer_range_operator = true
csharp_style_prefer_tuple_swap = true
csharp_style_prefer_utf8_string_literals = true
csharp_style_throw_expression = true
csharp_style_unused_value_assignment_preference = discard_variable:warning
csharp_style_unused_value_expression_statement_preference = discard_variable
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace:suggestion
# New line preferences
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = false:suggestion
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false:suggestion
csharp_style_allow_embedded_statements_on_same_line_experimental = true
#### C# Formatting Rules ####
# New line preferences
csharp_new_line_before_catch = false
csharp_new_line_before_else = false
csharp_new_line_before_finally = false
csharp_new_line_before_members_in_anonymous_types = false
csharp_new_line_before_members_in_object_initializers = false
csharp_new_line_before_open_brace = none
csharp_new_line_between_query_expression_clauses = false
# Indentation preferences
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = false
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
# Space preferences
csharp_space_after_cast = true
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Wrapping preferences
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
#### Naming styles ####
# Naming rules
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case

401
.gitignore vendored Normal file
View File

@ -0,0 +1,401 @@
assets/
build/
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml

25
TableturfBattle.sln Normal file
View File

@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.3.32819.101
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TableturfBattleServer", "TableturfBattleServer\TableturfBattleServer.csproj", "{60E18B13-8E94-4E29-9316-4C5FEA019A85}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{60E18B13-8E94-4E29-9316-4C5FEA019A85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{60E18B13-8E94-4E29-9316-4C5FEA019A85}.Debug|Any CPU.Build.0 = Debug|Any CPU
{60E18B13-8E94-4E29-9316-4C5FEA019A85}.Release|Any CPU.ActiveCfg = Release|Any CPU
{60E18B13-8E94-4E29-9316-4C5FEA019A85}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {099B14AF-71A1-49B0-8504-F3CD7516414D}
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,19 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "firefox",
"request": "launch",
"name": "Launch application in Firefox",
"url": "file:///${workspaceFolder}/index.html",
"webRoot": "${workspaceFolder}",
"preLaunchTask": {
"type": "typescript",
"tsconfig": "tsconfig.json",
}
}
]
}

View File

@ -0,0 +1,27 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "typescript",
"tsconfig": "tsconfig.json",
"problemMatcher": [
"$tsc"
],
"group": {
"kind": "build",
"isDefault": true
},
"label": "tsc: build - tsconfig.json"
},
{
"type": "typescript",
"tsconfig": "tsconfig.json",
"option": "watch",
"problemMatcher": [
"$tsc-watch"
],
"group": "build",
"label": "tsc: watch - tsconfig.json"
}
]
}

View File

@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Tableturf Battle</title>
<link rel="stylesheet" href="tableturf.css"/>
</head>
<body>
<div id="noJSSection">This application requires JavaScript.</div>
<div id="loadingSection">Loading game data...</div>
<div id="preGameSection" hidden>
<p><img title="Tableturf Battle" alt="Tableturf Battle logo" id="logo" src="assets/logo.png"></p>
<h1>Tableturf Battle</h1>
<p><input type="text" id="nameBox" placeholder="Choose a name"></p>
<p><button type="button" id="newGameButton">New game</button></p>
<p><input type="text" id="gameIDBox" placeholder="Game link or UUID"> <button type="button" id="joinGameButton">Join game</button></p>
<footer>
<p>This website is not affiliated with Nintendo. All product names, logos, and brands are property of their respective owners.</p>
<p><a href="https://github.com/AndrioCelos/TableturfBattleApp">GitHub</a></p>
</footer>
</div>
<div id="lobbySection" hidden>
<p>Waiting for players to join.</p>
<ul id="playerList"></ul>
</div>
<div id="deckSection" hidden>
<p>Choose your deck.</p>
<p><span id='countLabel'>0</span>/15 cards chosen</p>
<p><button type="button" id="submitDeckButton" disabled>Submit</button></p>
<p id="cardListLoadingSection">Loading cards...</p>
<ul id="cardList"></ul>
</div>
<div id="gameSection" hidden>
<div id="sidebarSection">
<div class="playerBar" data-index="1">
<div class="result"></div>
<div class="name">Player 2</div>
<div class="specialPoints">&nbsp;</div>
<div class="playerStats">
<div class="statSpecialPoints">
<div class="statLabel">Special points</div>
<div class="statValue">0</div>
</div>
<div class="statPasses">
<div class="statLabel">Turns passed</div>
<div class="statValue">0</div>
</div>
</div>
</div>
<div id="midGameContainer">
<div id="handContainer"></div>
<label for="passButton">
<input type="checkbox" id="passButton"/> Pass
</label>
<label for="specialButton">
<input type="checkbox" id="specialButton"/> Special Attack
</label>
</div>
<div id="resultContainer" hidden>
<div id="result">Result</div>
</div>
<div class="playerBar" data-index="0">
<div class="result"></div>
<div class="name">Player 1</div>
<div class="specialPoints">&nbsp;</div>
<div class="playerStats">
<div class="statSpecialPoints">
<div class="statLabel">Special points</div>
<div class="statValue">0</div>
</div>
<div class="statPasses">
<div class="statLabel">Turns passed</div>
<div class="statValue">0</div>
</div>
</div>
</div>
</div>
<div id="scoreSection">
<div id="turnNumberContainer" hidden>
<p>Turns left</p>
<div id="turnNumberLabel">12</div>
</div>
<div id="pointsContainers">
<div class="pointsContainer" data-index="1"><span class="points">69</span><span class="pointsToContainer">&rarrhk; <span class="pointsTo">60</span></span><span class="pointsDelta">+1</span></div>
<div class="pointsContainer" data-index="0"><span class="points">69</span><span class="pointsToContainer">&rarrhk; <span class="pointsTo">60</span></span><span class="pointsDelta">+1</span></div>
</div>
</div>
<div id="boardSection">
<table id="gameBoard"></table>
<div id="redrawModal" hidden>
<div id="redrawBox">
<p>Redraw your starting hand?</p>
<p><button type="button" id="redrawNoButton" data-redraw="false">Hold steady</button></p>
<p><button type="button" id="redrawYesButton" data-redraw="true">Redraw</button></p>
</div>
</div>
</div>
<div id="playsSection">
<div class="playContainer" data-index="1"></div>
<div class="playContainer" data-index="0"></div>
</div>
</div>
<div id="errorModal" hidden>
<div id="errorModalBox">A communication error has occurred.</div>
</div>
<script src="build/tsbuild.js"></script>
</body>
</html>

View File

@ -0,0 +1,203 @@
class Board {
table: HTMLTableElement;
grid: Space[][] = [ ];
cells: HTMLTableCellElement[][] = [ ];
playerIndex: number | null = 0;
cardPlaying: Card | null = null;
cardRotation = 0;
specialAttack = false;
autoHighlight = true;
highlightX = NaN;
highlightY = NaN;
private highlightedCells: [x: number, y: number][] = [ ];
onclick: ((x: number, y: number) => void) | null = null;
constructor(table: HTMLTableElement) {
this.table = table;
table.addEventListener('mouseleave', _ => {
if (this.autoHighlight) this.clearHighlight()
});
}
checkMoveLegality(playerIndex: number, card: Card, x: number, y: number, rotation: number, isSpecialAttack: boolean): string | null {
let isAnchored = false;
for (let dx = 0; dx < 8; dx++) {
for (let dy = 0; dy < 8; dy++) {
if (card.getSpace(dx, dy, rotation) == Space.Empty)
continue;
var x2 = x + dx;
var y2 = y + dy;
if (x2 < 0 || x2 >= this.grid.length || y2 < 0 || y2 >= this.grid[x2].length)
return "It cannot overlap a wall or out of bounds."; // Out of bounds.
const space = this.grid[x2][y2];
if (space == Space.Wall || space == Space.OutOfBounds)
return "It cannot overlap a wall or out of bounds.";
if (space >= Space.SpecialInactive1)
return "It cannot overlap a special space."
if (space != Space.Empty && !isSpecialAttack)
return "It cannot overlap existing ink without a special attack.";
if (!isAnchored) {
// A normal play must be adjacent to ink of the player's colour.
// A special attack must be adjacent to a special space of theirs.
for (let dy2 = -1; dy2 <= 1; dy2++) {
for (let dx2 = -1; dx2 <= 1; dx2++) {
if (dx2 == 0 && dy2 == 0) continue;
var x3 = x2 + dx2;
var y3 = y2 + dy2;
if (x3 < 0 || x3 >= this.grid.length || y3 < 0 || y3 >= this.grid[x3].length)
continue;
if (this.grid[x3][y3] >= (isSpecialAttack ? Space.SpecialInactive1 : Space.Ink1)
&& ((this.grid[x3][y3]) & 3) == playerIndex) {
isAnchored = true;
break;
}
}
}
}
}
}
return isAnchored ? null : (isSpecialAttack ? "It must be next to one of your special spaces." : "It must be next to your turf.");
}
refreshHighlight() {
let legal = this.playerIndex == null || this.cardPlaying == null ? false
: this.checkMoveLegality(this.playerIndex, this.cardPlaying, this.highlightX, this.highlightY, this.cardRotation, this.specialAttack) == null;
this.clearHighlight();
if (this.cardPlaying != null && this.playerIndex != null) {
for (let dx = 0; dx < 8; dx++) {
const x2 = this.highlightX + dx;
if (x2 < 0 || x2 >= this.grid.length)
continue;
for (let dy = 0; dy < 8; dy++) {
const y2 = this.highlightY + dy;
if (y2 < 0 || y2 >= this.grid[x2].length || this.grid[x2][y2] == Space.OutOfBounds)
continue;
const space = this.cardPlaying.getSpace(dx, dy, this.cardRotation);
if (space != Space.Empty) {
const cell = this.cells[x2][y2];
cell.classList.add('hover');
cell.classList.add(`hover${this.playerIndex + 1}`);
if (!legal)
cell.classList.add('hoverillegal');
if (space == Space.SpecialInactive1)
cell.classList.add('hoverspecial');
this.highlightedCells.push([x2, y2]);
}
}
}
}
}
clearHighlight() {
for (const [x, y] of this.highlightedCells) {
this.cells[x][y].setAttribute('class', Space[this.grid[x][y]] );
}
this.highlightedCells.splice(0);
}
resize(grid: Space[][]) {
if (grid.length == 0)
throw new Error('Grid must not be empty.');
this.grid = grid || this.grid;
while (this.table.firstChild) {
this.table.removeChild(this.table.firstChild);
}
this.cells.splice(0);
const boardWidth = grid.length;
const boardHeight = grid[0].length;
this.table.style.setProperty('--board-width', boardWidth.toString());
this.table.style.setProperty('--board-height', boardHeight.toString());
const trs: HTMLTableRowElement[] = [ ];
for (let y = 0; y < boardHeight; y++) {
const tr = document.createElement('tr');
trs.push(tr);
this.table.appendChild(tr);
}
for (let x = 0; x < boardWidth; x++) {
const col: HTMLTableCellElement[] = [ ];
for (let y = 0; y < grid[x].length; y++) {
const td = document.createElement('td');
td.dataset.x = x.toString();
td.dataset.y = y.toString();
trs[y].appendChild(td);
col.push(td);
td.addEventListener('mousemove', e => {
if (this.autoHighlight) {
const x = parseInt((e.target as HTMLTableCellElement).dataset.x!) - 3;
const y = parseInt((e.target as HTMLTableCellElement).dataset.y!) - 3;
if (x != this.highlightX || y != this.highlightY) {
this.highlightX = x;
this.highlightY = y;
this.refreshHighlight();
}
}
});
td.addEventListener('click', e => {
if (this.autoHighlight && this.onclick) {
const x = parseInt((e.target as HTMLTableCellElement).dataset.x!) - 3;
const y = parseInt((e.target as HTMLTableCellElement).dataset.y!) - 3;
this.onclick(x, y);
}
});
td.addEventListener('wheel', e => {
if (this.autoHighlight) {
e.preventDefault();
if (e.deltaY > 0) this.cardRotation++;
else if (e.deltaY < 0) this.cardRotation--;
this.refreshHighlight();
}
});
}
this.cells.push(col);
}
this.refresh();
}
refresh() {
this.clearHighlight();
for (let x = 0; x < this.grid.length; x++) {
for (let y = 0; y < this.grid[x].length; y++) {
this.cells[x][y].setAttribute('class', Space[this.grid[x][y]] );
}
}
}
getScore(playerIndex: number) {
let count = 0;
for (let x = 0; x < this.grid.length; x++) {
for (let y = 0; y < this.grid[x].length; y++) {
const space = this.grid[x][y];
if (space >= Space.Ink1 && (space & 3) == playerIndex)
count++;
}
}
return count;
}
/**
* Returns a value indicating whether a specified player can play the specified card anywhere.
*/
canPlayCard(playerIndex: number, card: Card, isSpecialAttack: boolean) {
for (let rotation = 0; rotation < 3; rotation++) {
for (let x = -4; x < this.grid.length - 3; x++) {
for (let y = -4; y < this.grid[0].length - 3; y++) {
if (this.checkMoveLegality(playerIndex, card, x, y, rotation, isSpecialAttack) == null)
return true;
}
}
}
return false;
}
}

View File

@ -0,0 +1,38 @@
class Card {
number: number;
name: string;
rarity: Rarity;
specialCost: number;
grid: Space[][];
size: number;
constructor(number: number, name: string, rarity: Rarity, specialCost: number, grid: Space[][]) {
this.number = number;
this.name = name;
this.rarity = rarity;
this.specialCost = specialCost;
this.grid = grid;
let size = 0;
for (let y = 0; y < 8; y++) {
for (let x = 0; x < 8; x++) {
if (grid[x][y] != Space.Empty)
size++;
}
}
this.size = size;
}
static fromJson(obj: any) {
return new Card(obj.number, obj.name, obj.rarity, obj.specialCost, obj.grid);
}
getSpace(x: number, y: number, rotation: number) {
switch (rotation & 3) {
case 0: return this.grid[x][y];
case 1: return this.grid[y][7 - x];
case 2: return this.grid[7 - x][7 - y];
default: return this.grid[7 - y][x];
}
}
}

View File

@ -0,0 +1,98 @@
class CardButton {
private static idNumber = 0;
readonly element: HTMLLabelElement;
readonly inputElement: HTMLInputElement;
readonly card: Card;
constructor(type: 'checkbox' | 'radio', card: Card) {
this.card = card;
let el = document.createElement('label');
this.element = el;
el.setAttribute('for', `card${CardButton.idNumber}`);
el.setAttribute('type', 'checkbox')
el.classList.add('card');
el.classList.add([ 'common', 'rare', 'fresh' ][card.rarity]);
let size = 0;
let table = document.createElement('table');
table.classList.add('cardGrid');
for (var y = 0; y < 8; y++) {
let tr = document.createElement('tr');
table.appendChild(tr);
for (var x = 0; x < 8; x++) {
let td = document.createElement('td');
if (card.grid[x][y] == Space.Ink1) {
size++;
td.classList.add('ink');
} else if (card.grid[x][y] == Space.SpecialInactive1) {
size++;
td.classList.add('special');
}
tr.appendChild(td);
}
}
let row = document.createElement('div');
row.className = 'cardHeader';
el.appendChild(row);
let checkBox = document.createElement('input');
this.inputElement = checkBox;
checkBox.setAttribute('type', type)
checkBox.id = `card${CardButton.idNumber}`;
checkBox.dataset.number = card.number.toString();
checkBox.addEventListener('change', e => {
if (checkBox.checked)
el.classList.add('checked');
else
el.classList.remove('checked');
});
row.appendChild(checkBox);
let el2 = document.createElement('div');
el2.classList.add('cardNumber');
el2.innerText = card.number.toString();
row.appendChild(el2);
el2 = document.createElement('div');
el2.classList.add('cardName');
el2.innerText = card.name;
row.appendChild(el2);
el.appendChild(table);
row = document.createElement('div');
row.className = 'cardFooter';
el.appendChild(row);
el2 = document.createElement('div');
el2.classList.add('cardSize');
el2.innerText = size.toString();
row.appendChild(el2);
el2 = document.createElement('div');
el2.classList.add('cardSpecialCost');
row.appendChild(el2);
for (let i = 1; i <= card.specialCost; i++) {
const el3 = document.createElement('div');
el3.classList.add('cardSpecialPoint');
el3.innerText = i.toString();
el2.appendChild(el3);
}
CardButton.idNumber++;
}
get enabled() { return !this.inputElement.disabled; }
set enabled(value: boolean) {
this.inputElement.disabled = !value;
if (value)
this.element.classList.remove('disabled');
else
this.element.classList.add('disabled');
}
}

View File

@ -0,0 +1,31 @@
const cardDatabase = {
cards: null as Card[] | null,
loadAsync() {
return new Promise<Card[]>((resolve, reject) => {
if (cardDatabase.cards != null) {
resolve(cardDatabase.cards);
return;
}
const cardListRequest = new XMLHttpRequest();
cardListRequest.open('GET', 'http://localhost:3333/api/cards');
cardListRequest.addEventListener('load', e => {
const cards = [ ];
if (cardListRequest.status == 200) {
const s = cardListRequest.responseText;
const response = JSON.parse(s) as object[];
for (const o of response) {
cards.push(Card.fromJson(o));
}
cardDatabase.cards = cards;
resolve(cards);
} else {
reject(new Error(`Error downloading card database: response was ${cardListRequest.status}`));
}
});
cardListRequest.addEventListener('error', e => {
reject(new Error('Error downloading card database: no further information.'))
});
cardListRequest.send();
});
}
}

View File

@ -0,0 +1,7 @@
enum GameState {
WaitingForPlayers,
Preparing,
Redraw,
Ongoing,
Ended
}

View File

@ -0,0 +1,23 @@
/** A UUID used to identify the client. */
let clientToken = window.localStorage.getItem('clientToken') || '';
/** The data of the current game, or null if not in a game. */
let currentGame: {
id: string,
/** The list of players in the current game. May have empty slots. */
players: (Player | null)[],
/** The user's player data, or null if they are spectating. */
me: {
playerIndex: number,
hand: Card[] | null,
deck: Card[] | null,
cardsUsed: number[]
} | null,
/** The WebSocket used for receiving game events, or null if not yet connected. */
webSocket: WebSocket
} | null;
const playerList = document.getElementById('playerList')!;
const playerListItems: HTMLElement[] = [ ];
const canPlayCard = [ false, false, false, false ];
const canPlayCardAsSpecialAttack = [ false, false, false, false ];

View File

@ -0,0 +1,12 @@
interface Move {
card: Card;
isPass: boolean;
}
interface PlayMove extends Move {
isPass: true;
x: number;
y: number;
rotation: number;
isSpecialAttack: boolean;
}

View File

@ -0,0 +1,314 @@
const board = new Board(document.getElementById('gameBoard') as HTMLTableElement);
const turnNumberLabel = new TurnNumberLabel(document.getElementById('turnNumberContainer')!, document.getElementById('turnNumberLabel')!);
const handButtons: CardButton[] = [ ];
const passButton = document.getElementById('passButton') as HTMLInputElement;
const specialButton = document.getElementById('specialButton') as HTMLInputElement;
const handContainer = document.getElementById('handContainer')!;
const redrawModal = document.getElementById('redrawModal')!;
const midGameContainer = document.getElementById('midGameContainer')!;
const resultContainer = document.getElementById('resultContainer')!;
const resultElement = document.getElementById('result')!;
const playerBars = Array.from(document.getElementsByClassName('playerBar'), el => new PlayerBar(el as HTMLDivElement));
playerBars.sort((a, b) => a.playerIndex - b.playerIndex);
const playContainers = Array.from(document.getElementsByClassName('playContainer')) as HTMLDivElement[];
playContainers.sort((a, b) => parseInt(a.dataset.index || '0') - parseInt(b.dataset.index || '0'));
let testMode = false;
let canPlay = false;
let cols: Space[][] = [ ];
for (let x = 0; x < 9; x++) {
const col = [ ];
for (let y = 0; y < 26; y++)
col.push(Space.Empty);
cols.push(col);
}
cols[4][4] = Space.SpecialInactive2;
cols[4][21] = Space.SpecialInactive1;
board.resize(cols);
function loadPlayers(players: (Player | null)[]) {
for (let i = 0; i < players.length; i++) {
const player = players[i];
if (player != null) {
currentGame!.players[i] = players[i];
playerBars[i].name = player.name;
updateStats(i);
document.body.style.setProperty(`--primary-colour-${i + 1}`, `rgb(${player.colour.r}, ${player.colour.g}, ${player.colour.b})`);
document.body.style.setProperty(`--special-colour-${i + 1}`, `rgb(${player.specialColour.r}, ${player.specialColour.g}, ${player.specialColour.b})`);
document.body.style.setProperty(`--special-accent-colour-${i + 1}`, `rgb(${player.specialAccentColour.r}, ${player.specialAccentColour.g}, ${player.specialAccentColour.b})`);
}
}
}
function updateStats(playerIndex: number) {
if (currentGame == null) return;
playerBars[playerIndex].points = board.getScore(playerIndex);
playerBars[playerIndex].pointsDelta = 0;
playerBars[playerIndex].pointsTo = 0;
playerBars[playerIndex].specialPoints = currentGame.players[playerIndex]!.specialPoints;
playerBars[playerIndex].statSpecialPointsElement.innerText = currentGame.players[playerIndex]!.totalSpecialPoints.toString();
playerBars[playerIndex].statPassesElement.innerText = currentGame.players[playerIndex]!.passes.toString();
}
function showReady(playerIndex: number) {
const el = document.createElement('div');
el.className = 'cardBack';
el.innerText = 'Ready';
playContainers[playerIndex].appendChild(el);
}
function clearPlayContainers() {
for (const container of playContainers) {
while (container.firstChild)
container.removeChild(container.firstChild);
}
}
function setupControlsForPlay() {
passButton.checked = false;
specialButton.checked = false;
board.specialAttack = false;
board.cardPlaying = null;
if (canPlay && currentGame?.me?.hand != null) {
passButton.disabled = false;
for (let i = 0; i < 4; i++) {
canPlayCard[i] = board.canPlayCard(currentGame.me.playerIndex, currentGame.me.hand[i], false);
canPlayCardAsSpecialAttack[i] = currentGame.players[currentGame.me.playerIndex]!.specialPoints >= currentGame.me.hand[i].specialCost
&& board.canPlayCard(currentGame.me.playerIndex, currentGame.me.hand[i], true);
handButtons[i].inputElement.disabled = !canPlayCard[i];
}
specialButton.disabled = !canPlayCardAsSpecialAttack.includes(true);
board.autoHighlight = true;
} else {
for (const button of handButtons) {
button.inputElement.disabled = true;
passButton.disabled = true;
specialButton.disabled = true;
}
}
}
async function playInkAnimations(data: {
game: { state: GameState, board: Space[][], turnNumber: number, players: (Player | null)[] },
placements: { cards: { playerIndex: number, card: Card }[], spacesAffected: { space: { x: number, y: number }, newState: Space }[] }[],
specialSpacesActivated: { x: number, y: number }[]
}, anySpecialAttacks: boolean) {
const inkPlaced = new Set<number>();
const placements = data.placements;
board.clearHighlight();
canPlay = false;
board.autoHighlight = false;
await delay(anySpecialAttacks ? 3000 : 1000);
for (const placement of placements) {
// Skip the delay when cards don't overlap.
if (placement.spacesAffected.find(p => inkPlaced.has(p.space.y * 37 + p.space.x))) {
inkPlaced.clear();
await delay(1000);
}
for (const p of placement.spacesAffected) {
inkPlaced.add(p.space.y * 37 + p.space.x);
board.grid[p.space.x][p.space.y] = p.newState;
}
board.refresh();
}
await delay(1000);
// Show special spaces.
board.grid = data.game.board;
board.refresh();
if (data.specialSpacesActivated.length > 0)
await delay(1000); // Delay if we expect that this changed the board.
for (let i = 0; i < playerBars.length; i++) {
const player = data.game.players[i];
if (player)
playerBars[i].specialPoints = player.specialPoints;
}
for (let i = 0; i < playerBars.length; i++) {
playerBars[i].pointsDelta = board.getScore(i) - playerBars[i].points;
}
await delay(1000);
for (let i = 0; i < playerBars.length; i++) {
updateStats(i);
}
await delay(1000);
}
function showResult() {
midGameContainer.hidden = true;
resultContainer.hidden = false;
let winners = [ 0 ]; let maxPoints = playerBars[0].points;
for (let i = 1; i < playerBars.length; i++) {
if (playerBars[i].points > maxPoints) {
winners.splice(0);
winners.push(i);
maxPoints = playerBars[i].points;
} else if (playerBars[i].points == maxPoints)
winners.push(i);
}
if (currentGame == null) return;
for (let i = 0; i < currentGame.players.length; i++) {
const el = playerBars[i].resultElement;
if (winners.includes(i)) {
if (winners.length == 1) {
el.classList.add('win');
el.classList.remove('lose');
el.classList.remove('draw');
el.innerText = 'Win!';
} else {
el.classList.remove('win');
el.classList.remove('lose');
el.classList.add('draw');
el.innerText = 'Draw!';
}
} else {
el.classList.remove('win');
el.classList.add('lose');
el.classList.remove('draw');
el.innerText = 'Lose...';
}
}
}
function updateHand(cards: any[]) {
for (const button of handButtons) {
handContainer.removeChild(button.element);
}
handButtons.splice(0);
if (!currentGame?.me) return;
currentGame.me.hand = cards.map(Card.fromJson);
if (cards) {
for (const card of currentGame.me.hand) {
const button = new CardButton('radio', card);
button.inputElement.name = 'handCard';
handButtons.push(button);
button.inputElement.addEventListener('change', e => {
if (button.inputElement.checked) {
if (passButton.checked) {
if (canPlay) {
canPlay = false;
// Send the play to the server.
let req = new XMLHttpRequest();
req.open('POST', `http://localhost:3333/api/games/${currentGame!.id}/play`);
req.addEventListener('load', e => {
if (req.status == 204) {
}
});
let data = new URLSearchParams();
data.append('clientToken', clientToken);
data.append('cardNumber', card.number.toString());
data.append('isPass', 'true');
req.send(data.toString());
board.autoHighlight = false;
}
}
board.cardPlaying = card;
board.cardRotation = 0;
}
});
handContainer.appendChild(button.element);
}
}
}
(document.getElementById('redrawNoButton') as HTMLButtonElement).addEventListener('click', redrawButton_click);
(document.getElementById('redrawYesButton') as HTMLButtonElement).addEventListener('click', redrawButton_click);
function redrawButton_click(e: MouseEvent) {
let req = new XMLHttpRequest();
req.open('POST', `http://localhost:3333/api/games/${currentGame!.id}/redraw`);
let data = new URLSearchParams();
data.append('clientToken', clientToken);
data.append('redraw', (e.target as HTMLButtonElement).dataset.redraw!);
req.send(data.toString());
redrawModal.hidden = true;
}
passButton.addEventListener('change', e => {
board.autoHighlight = !passButton.checked;
if (passButton.checked) {
specialButton.checked = false;
board.cardPlaying = null;
board.specialAttack = false;
for (const el of handButtons) {
el.inputElement.disabled = false;
el.inputElement.checked = false;
}
} else {
for (let i = 0; i < 4; i++) {
handButtons[i].inputElement.disabled = !canPlayCard[i];
}
}
});
specialButton.addEventListener('change', e => {
board.specialAttack = specialButton.checked;
if (specialButton.checked) {
passButton.checked = false;
board.autoHighlight = true;
for (let i = 0; i < 4; i++) {
handButtons[i].inputElement.disabled = !canPlayCardAsSpecialAttack[i];
if (!canPlayCardAsSpecialAttack[i])
handButtons[i].inputElement.checked = false;
}
} else {
for (let i = 0; i < 4; i++) {
handButtons[i].inputElement.disabled = !canPlayCard[i];
if (!canPlayCard[i])
handButtons[i].inputElement.checked = false;
}
}
});
board.onclick = (x, y) => {
if (board.cardPlaying == null || !currentGame?.me)
return;
const message = board.checkMoveLegality(currentGame.me.playerIndex, board.cardPlaying, x, y, board.cardRotation, board.specialAttack);
if (message != null) {
alert(message);
return;
}
if (testMode) {
for (let dy = 0; dy < 8; dy++) {
for (let dx = 0; dx < 8; dx++) {
let space = board.cardPlaying.getSpace(dx, dy, board.cardRotation);
if (space != Space.Empty) {
board.grid[x + dx][y + dy] = space;
}
}
}
board.refresh();
} else if (canPlay) {
canPlay = false;
// Send the play to the server.
let req = new XMLHttpRequest();
req.open('POST', `http://localhost:3333/api/games/${currentGame.id}/play`);
req.addEventListener('load', e => {
if (req.status != 204) {
alert(req.responseText);
board.clearHighlight();
board.autoHighlight = true;
}
});
let data = new URLSearchParams();
data.append('clientToken', clientToken);
data.append('cardNumber', board.cardPlaying.number.toString());
data.append('isSpecialAttack', specialButton.checked.toString());
data.append('x', board.highlightX.toString());
data.append('y', board.highlightY.toString());
data.append('r', board.cardRotation.toString());
req.send(data.toString());
board.autoHighlight = false;
}
};

View File

@ -0,0 +1,46 @@
let cardButtons: CardButton[] = [ ];
let submitDeckButton = document.getElementById('submitDeckButton') as HTMLButtonElement;
function clearReady() {
if (currentGame == null) return;
for (var i = 0; i < currentGame.players.length; i++) {
const player = currentGame.players[i];
if (player != null) {
player.isReady = false;
updatePlayerListItem(i);
}
}
}
function updatePlayerListItem(playerIndex: number) {
const player = currentGame != null ? currentGame.players[playerIndex] : null;
const listItem = playerListItems[playerIndex];
if (player != null) {
listItem.innerText = player.name;
if (player)
listItem.innerText += ' (Ready)';
} else
listItem.innerText = "Waiting...";
}
submitDeckButton.addEventListener('click', e => {
let req = new XMLHttpRequest();
req.open('POST', `http://localhost:3333/api/games/${currentGame!.id}/chooseDeck`);
req.addEventListener('load', e => {
if (req.status == 204) {
showSection('lobby');
}
});
let data = new URLSearchParams();
let cardsString = '';
for (var el of cardButtons) {
if (el.inputElement.checked) {
if (cardsString != '') cardsString += '+';
cardsString += el.card.number.toString();
}
}
data.append('clientToken', clientToken);
data.append('deckName', 'Deck');
data.append('deckCards', cardsString);
req.send(data.toString());
});

View File

@ -0,0 +1,201 @@
const newGameButton = document.getElementById('newGameButton')!;
const joinGameButton = document.getElementById('joinGameButton')!;
newGameButton.addEventListener('click', e => {
const name = (document.getElementById('nameBox') as HTMLInputElement).value;
window.localStorage.setItem('name', name);
let request = new XMLHttpRequest();
request.open('POST', 'http://localhost:3333/api/games/new');
request.addEventListener('load', e => {
if (request.status == 200) {
let response = JSON.parse(request.responseText);
if (!clientToken)
setClientToken(response.clientToken);
getGameInfo(response.gameID, 0);
}
});
let data = new URLSearchParams();
data.append('name', name);
data.append('clientToken', clientToken);
request.send(data.toString());
});
joinGameButton.addEventListener('click', e => {
const name = (document.getElementById('nameBox') as HTMLInputElement).value;
window.localStorage.setItem('name', name);
tryJoinGame(name, (document.getElementById('gameIDBox') as HTMLInputElement).value);
});
function tryJoinGame(name: string, idOrUrl: string) {
const gameID = idOrUrl.substring(idOrUrl.lastIndexOf('#') + 1);
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(gameID)) {
alert("Invalid game ID or link");
return;
}
let request = new XMLHttpRequest();
request.open('POST', `http://localhost:3333/api/games/${gameID}/join`);
request.addEventListener('load', e => {
if (request.status == 200) {
let response = JSON.parse(request.responseText);
if (!clientToken)
setClientToken(response.clientToken);
getGameInfo(gameID, response.playerIndex);
} else if (request.status == 404) {
alert("The game was not found.");
window.location.hash = '#';
} else if (request.status == 410) {
alert("The game has already started.");
window.location.hash = '#';
}
});
let data = new URLSearchParams();
data.append('name', name);
data.append('clientToken', clientToken);
request.send(data.toString());
}
function getGameInfo(gameID: string, myPlayerIndex: number | null) {
board.playerIndex = myPlayerIndex;
window.location.hash = `#${gameID}`;
const webSocket = new WebSocket(`ws://localhost:3333/api/websocket?gameID=${gameID}&clientToken=${clientToken}`);
webSocket.addEventListener('open', e => {
const request2 = new XMLHttpRequest();
request2.open('GET', `http://localhost:3333/api/games/${gameID}/playerData/${clientToken}`);
request2.addEventListener('load', e => {
if (request2.status == 200) {
const response = JSON.parse(request2.responseText);
const playerData = response.playerData as PlayerData | null;
currentGame = {
id: gameID,
me: playerData != null ? { playerIndex: playerData.playerIndex, hand: playerData.hand?.map(Card.fromJson) || null, deck: playerData.deck?.map(Card.fromJson) || null, cardsUsed: playerData.cardsUsed } : null,
players: response.game.players,
webSocket: webSocket
};
for (const li of playerListItems)
playerList.removeChild(li);
playerListItems.splice(0);
for (var player of currentGame.players) {
var el = document.createElement('li');
if (player)
el.innerText = player.name;
else
el.innerText = "Waiting...";
playerListItems.push(el);
playerList.appendChild(el);
}
onGameStateChange(response.game, playerData);
for (let i = 0; i < response.game.players.length; i++) {
if (response.game.players[i]?.isReady)
showReady(i);
}
if (playerData) {
myPlayerIndex = playerData.playerIndex;
if (playerData.move) {
canPlay = false;
board.autoHighlight = false;
if (!playerData.move.isPass) {
const move = playerData.move as PlayMove;
board.cardPlaying = Card.fromJson(move.card);
board.highlightX = move.x;
board.highlightY = move.y;
board.cardRotation = move.rotation;
board.specialAttack = move.isSpecialAttack;
board.refreshHighlight();
}
}
}
}
});
request2.send();
});
webSocket.addEventListener('message', e => {
if (currentGame == null) return;
let s = e.data as string;
console.log(`>> ${s}`);
if (s) {
let payload = JSON.parse(s);
if (payload.event == 'join') {
currentGame.players[payload.data.playerIndex] = payload.data.player;
playerListItems[payload.data.playerIndex].innerText = payload.data.player.name;
updatePlayerListItem(payload.data.playerIndex);
} else if (payload.event == 'playerReady') {
currentGame.players[payload.data.playerIndex]!.isReady = true;
updatePlayerListItem(payload.data.playerIndex);
if (playContainers[payload.data.playerIndex].getElementsByTagName('div').length == 0) {
showReady(payload.data.playerIndex);
}
} else if (payload.event == 'stateChange') {
clearReady();
onGameStateChange(payload.data, payload.playerData);
} else if (payload.event == 'turn' || payload.event == 'gameEnd') {
clearReady();
board.autoHighlight = false;
showSection('game');
(async () => {
let anySpecialAttacks = false;
// Show the cards that were played.
clearPlayContainers();
for (let i = 0; i < currentGame.players.length; i++) {
const player = currentGame.players[i];
if (player != null) {
player.specialPoints = payload.data.game.players[i].specialPoints;
player.totalSpecialPoints = payload.data.game.players[i].totalSpecialPoints;
player.passes = payload.data.game.players[i].passes;
const move = payload.data.moves[i];
const button = new CardButton('checkbox', move.card);
if (move.isSpecialAttack) {
anySpecialAttacks = true;
button.element.classList.add('specialAttack');
} else if (move.isPass) {
const el = document.createElement('div');
el.className = 'passLabel';
el.innerText = 'Pass';
button.element.appendChild(el);
}
button.inputElement.hidden = true;
playContainers[i].append(button.element);
}
}
await playInkAnimations(payload.data, anySpecialAttacks);
updateHand(payload.playerData.hand);
turnNumberLabel.setTurnNumber(payload.data.game.turnNumber);
clearPlayContainers();
if (payload.event == 'gameEnd') {
document.getElementById('gameSection')!.classList.add('gameEnded');
showResult();
} else {
canPlay = myPlayerIndex != null;
board.autoHighlight = canPlay;
setupControlsForPlay();
}
})();
}
}
});
webSocket.addEventListener('close', e => {
document.getElementById('errorModal')!.hidden = false;
});
}
{
const name = window.localStorage.getItem('name');
(document.getElementById('nameBox') as HTMLInputElement).value = name || '';
if (window.location.hash != '') {
(document.getElementById('gameIDBox') as HTMLInputElement).value = window.location.hash;
if (name != null)
tryJoinGame(name, window.location.hash)
}
}

View File

@ -0,0 +1,16 @@
interface Player {
name: string;
specialPoints: number;
isReady: boolean;
colour: Colour;
specialColour: Colour;
specialAccentColour: Colour;
totalSpecialPoints: number;
passes: number;
}
interface Colour {
r: number;
g: number;
b: number;
}

View File

@ -0,0 +1,88 @@
class PlayerBar {
readonly playerIndex: number;
private nameElement: HTMLElement;
private specialPointsContainer: HTMLElement;
private pointsElement: HTMLElement;
private pointsContainer: HTMLElement;
private pointsToContainer: HTMLElement;
private pointsToElement: HTMLElement;
private pointsDeltaElement: HTMLElement;
resultElement: HTMLElement;
statSpecialPointsElement: HTMLElement;
statPassesElement: HTMLElement;
constructor(element: HTMLDivElement) {
this.playerIndex = parseInt(element.dataset.index!);
if (isNaN(this.playerIndex))
throw new Error('Missing player index');
this.nameElement = element.getElementsByClassName('name')[0] as HTMLElement;
this.specialPointsContainer = element.getElementsByClassName('specialPoints')[0] as HTMLElement;
let pointsContainer: HTMLElement | null = null;
for (const el of document.getElementsByClassName('pointsContainer')) {
if ((el as HTMLElement).dataset.index == element.dataset.index) {
pointsContainer = el as HTMLElement;
break;
}
}
if (pointsContainer == null)
throw new Error(`pointsContainer with index ${element.dataset.index} not found`);
this.pointsContainer = pointsContainer;
this.pointsElement = pointsContainer.getElementsByClassName('points')[0] as HTMLElement;
this.pointsToContainer = pointsContainer.getElementsByClassName('pointsToContainer')[0] as HTMLElement;
this.pointsToElement = pointsContainer.getElementsByClassName('pointsTo')[0] as HTMLElement;
this.pointsDeltaElement = pointsContainer.getElementsByClassName('pointsDelta')[0] as HTMLElement;
this.resultElement = element.getElementsByClassName('result')[0] as HTMLElement;
this.statSpecialPointsElement = element.querySelector('.statSpecialPoints .statValue')!;
this.statPassesElement = element.querySelector('.statPasses .statValue')!;
}
get name() { return this.nameElement.innerText; }
set name(value: string) { this.nameElement.innerText = value; }
get points() { return parseInt(this.pointsElement.innerText); }
set points(value: number) { this.pointsElement.innerText = value.toString(); }
get specialPoints() { return this.specialPointsContainer.getElementsByClassName('specialPoint').length; }
set specialPoints(value: number) {
const oldList = this.specialPointsContainer.getElementsByClassName('specialPoint');
if (value < oldList.length) {
for (let i = oldList.length - 1; i >= value; i--)
this.specialPointsContainer.removeChild(oldList[i]);
} else if (value > oldList.length) {
for (let i = oldList.length; i < value; i++) {
const el = document.createElement('div');
el.classList.add('specialPoint');
el.innerText = `${i + 1}`;
this.specialPointsContainer.appendChild(el);
}
}
}
get pointsTo() { return this.pointsToContainer.hidden ? null : parseInt(this.pointsToElement.innerText); }
set pointsTo(value: number | null) {
if (value == null || value == 0) // A player can never actually have 0 points because they start with a permanent special space.
this.pointsToContainer.hidden = true;
else {
this.pointsToContainer.hidden = false;
this.pointsToElement.innerText = value.toString();
}
}
get pointsDelta() { return this.pointsDeltaElement.hidden ? null : parseInt(this.pointsDeltaElement.innerText); }
set pointsDelta(value: number | null) {
if (value == null || value == 0)
this.pointsDeltaElement.hidden = true;
else {
this.pointsDeltaElement.hidden = false;
if (value > 0)
this.pointsDeltaElement.innerText = `+${value}`;
else
this.pointsDeltaElement.innerText = value.toString();
}
}
}

View File

@ -0,0 +1,7 @@
interface PlayerData {
playerIndex: number;
hand: Card[] | null;
deck: Card[] | null;
cardsUsed: number[];
move: Move | null;
}

View File

@ -0,0 +1,5 @@
enum Rarity {
Common,
Rare,
Fresh
}

View File

@ -0,0 +1,17 @@
enum Space {
Empty = 0,
Wall = 1,
OutOfBounds = 2,
Ink1 = 4,
Ink2 = 5,
Ink3 = 6,
Ink4 = 7,
SpecialInactive1 = 8,
SpecialInactive2 = 9,
SpecialInactive3 = 10,
SpecialInactive4 = 11,
SpecialActive1 = 12,
SpecialActive2 = 13,
SpecialActive3 = 14,
SpecialActive4 = 15
}

View File

@ -0,0 +1,22 @@
class TurnNumberLabel {
containerElement: HTMLElement;
element: HTMLElement;
constructor(conatinerElement: HTMLElement, element: HTMLElement) {
this.containerElement = conatinerElement;
this.element = element;
}
setTurnNumber(value: number | null) {
if (value == null)
this.containerElement.hidden = true;
else {
this.containerElement.hidden = false;
this.element.innerText = (13 - value).toString();
if (value >= 10)
this.element.classList.add('nowOrNever');
else
this.element.classList.remove('nowOrNever');
}
}
}

View File

@ -0,0 +1 @@
// This file was generated using TypeScript.

View File

@ -0,0 +1,103 @@
/// <reference path="CardDatabase.ts"/>
function delay(ms: number) { return new Promise(resolve => setTimeout(() => resolve(null), ms)); }
// Sections
const sections = new Map<string, HTMLDivElement>();
for (var id of [ 'noJS', 'loading', 'preGame', 'lobby', 'deck', 'game' ]) {
let el = document.getElementById(`${id}Section`) as HTMLDivElement;
if (!el) throw new EvalError(`Element not found: ${id}Section`);
sections.set(id, el);
}
function showSection(key: string) {
for (const [key2, el] of sections) {
el.hidden = key2 != key;
}
}
function setClientToken(token: string) {
window.localStorage.setItem('clientToken', token);
clientToken = token;
}
function onGameStateChange(game: any, playerData: any) {
if (currentGame == null)
throw new Error('currentGame is null');
clearPlayContainers();
board.resize(game.board);
board.refresh();
loadPlayers(game.players);
redrawModal.hidden = true;
document.getElementById('gameSection')!.classList.remove('gameEnded');
switch (game.state) {
case GameState.WaitingForPlayers:
showSection('lobby');
break;
case GameState.Preparing:
showSection('deck');
for (const button of cardButtons.slice(0, 15)) {
button.inputElement.checked = true;
}
submitDeckButton.disabled = false;
break;
case GameState.Redraw:
case GameState.Ongoing:
case GameState.Ended:
if (playerData)
updateHand(playerData.hand);
board.autoHighlight = false;
redrawModal.hidden = true;
showSection('game');
switch (game.state) {
case GameState.Redraw:
redrawModal.hidden = false;
turnNumberLabel.setTurnNumber(null);
canPlay = false;
break;
case GameState.Ongoing:
turnNumberLabel.setTurnNumber(game.turnNumber);
board.autoHighlight = true;
canPlay = currentGame.me != null && !currentGame.players[currentGame.me.playerIndex]?.isReady;
setupControlsForPlay();
break;
case GameState.Ended:
document.getElementById('gameSection')!.classList.add('gameEnded');
turnNumberLabel.setTurnNumber(null);
canPlay = false;
showResult();
break;
}
break;
}
}
showSection('preGame');
cardDatabase.loadAsync().then(cards => {
const cardList = document.getElementById('cardList')!;
for (var card of cards) {
const button = new CardButton('checkbox', card);
cardButtons.push(button);
button.inputElement.addEventListener('input', e => {
var count = 0;
for (var el of cardButtons) {
if (el.inputElement.checked)
count++;
}
document.getElementById('countLabel')!.innerText = count.toString();
submitDeckButton.disabled = (count != 15);
});
cardList.appendChild(button.element);
}
document.getElementById('cardListLoadingSection')!.hidden = true;
}).catch(e => document.getElementById('errorModal')!.hidden = false);
function isInternetExplorer() {
return !!(window.document as any).documentMode; // This is a non-standard property implemented only by Internet Explorer.
}
if (isInternetExplorer()) {
alert("You seem to be using an unsupported browser. Some layout or features of this app may not work correctly.");
}

View File

@ -0,0 +1,519 @@
/* Fonts */
@font-face {
font-family: 'Splatoon 1';
src: url('assets/splatoon1.woff2') format('woff2');
}
@font-face {
font-family: 'Splatoon 2';
src: url('assets/splatoon2.woff2') format('woff2');
}
/* Body */
body {
color: white;
background: black;
font-family: 'Splatoon 2', sans-serif;
/* Default colours - normally the game data sent from the server will override these */
--primary-colour-1 : hsl(63, 99%, 49%);
--special-colour-1 : hsl(38, 100%, 49%);
--special-accent-colour-1: hsl(60, 95%, 55%);
--primary-colour-2 : hsl(234, 97%, 64%);
--special-colour-2 : hsl(184, 99%, 50%);
--special-accent-colour-2: hsl(180, 17%, 86%);
--primary-colour-3 : hsl(306, 95%, 50%);
--special-colour-3 : hsl(270, 95%, 50%);
--special-accent-colour-3: hsl(285, 95%, 85%);
--primary-colour-4 : hsl(155, 95%, 50%);
--special-colour-4 : hsl(120, 95%, 50%);
--special-accent-colour-4: hsl(135, 95%, 85%);
}
/* Start section */
#preGameSection {
text-align: center;
}
#logo {
height: 8em;
}
h1 {
font-family: 'Splatoon 1', sans-serif;
font-weight: normal;
}
footer {
font-family: sans-serif;
}
/* Cards */
.cardBack {
width: 10rem;
height: 13rem;
border: 1px solid grey;
border-radius: 0.5rem;
display: flex;
justify-content: center;
align-items: center;
font-size: large;
}
.card {
font-family: 'Splatoon 1', 'Arial Black', sans-serif;
font-weight: 599;
display: inline-block;
border: 1px solid;
border-radius: 0.5em;
width: 10em;
margin: 5px;
position: relative;
}
.card.common {
border-color: #5931FF;
}
.card.rare {
border-color: yellow;
}
.card.fresh {
border-color: white;
}
.cardHeader {
height: 2.5em;
display: flex;
position: relative;
align-items: center;
padding: 0 5px;
justify-content: center;
}
.card input {
position: absolute;
z-index: -1;
left: 0;
top: 0;
}
.cardNumber {
display: none;
}
.cardName {
text-align: center;
line-height: 1.25em;
flex-grow: 1;
}
.card.common .cardName {
color: rgb(89, 49, 255);
}
.card.rare .cardName {
background: yellow;
background: -webkit-linear-gradient(0, rgb(255, 242, 129) 0%, rgb(255, 255, 224) 15%, rgb(231, 180, 39) 50%, rgb(255, 255, 224) 85%, rgb(255, 242, 129) 100%);
background-clip: text;
color: transparent;
}
.card.fresh .cardName {
background: white;
background: -webkit-linear-gradient(-30deg, rgba(253, 217, 169, 1) 0%, rgba(200, 58, 141, 1) 50%, rgba(55, 233, 207, 1) 100%);
background-clip: text;
color: transparent;
flex-grow: 0;
}
.cardGrid {
margin: 0 auto;
}
.cardGrid td {
width: 0.5em;
height: 0.5em;
border: 1px solid gray;
}
.cardGrid td.ink { background: hsl(63, 99%, 49%); }
.cardGrid td.special { background: hsl(38, 100%, 49%); }
.cardFooter {
display: flex;
align-items: center;
height: 2.5em;
}
.cardSize {
display: inline-block;
width: 2em;
text-align: center;
}
.cardSpecialCost {
display: inline-flex;
width: 6em;
flex-wrap: wrap-reverse;
row-gap: 4px;
}
.cardSpecialPoint {
display: inline-block;
color: transparent;
background: var(--special-colour-1);
width: 1ch;
height: 1ch;
margin-right: 0.3em;
}
/* Board */
#gameBoard {
height: 98vh;
aspect-ratio: var(--board-width)/var(--board-height);
table-layout: fixed;
border-spacing: 0;
}
#gameBoard td { border: 1px solid grey; }
#gameBoard td.Wall { background: grey; }
#gameBoard td.Ink1 { background: var(--primary-colour-1); }
#gameBoard td.Ink2 { background: var(--primary-colour-2); }
#gameBoard td.SpecialInactive1 { background: var(--special-colour-1); }
#gameBoard td.SpecialInactive2 { background: var(--special-colour-2); }
#gameBoard td.SpecialActive1 { background: radial-gradient(circle, var(--special-accent-colour-1) 25%, var(--special-colour-1) 75%); }
#gameBoard td.SpecialActive2 { background: radial-gradient(circle, var(--special-accent-colour-2) 25%, var(--special-colour-2) 75%); }
#gameBoard td.hover {
position: relative;
}
#gameBoard td.hover::after {
content: '';
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
opacity: 0.5;
}
#gameBoard td.hover1:not(.hoverillegal)::after { background: var(--primary-colour-1); }
#gameBoard td.hover2:not(.hoverillegal)::after { background: var(--primary-colour-2); }
#gameBoard td.hover3:not(.hoverillegal)::after { background: var(--primary-colour-3); }
#gameBoard td.hover4:not(.hoverillegal)::after { background: var(--primary-colour-4); }
#gameBoard td.hoverspecial.hover1:not(.hoverillegal)::after { background: var(--special-colour-1); }
#gameBoard td.hoverspecial.hover1:not(.hoverillegal)::after { background: var(--special-colour-2); }
#gameBoard td.hoverspecial.hover1:not(.hoverillegal)::after { background: var(--special-colour-3); }
#gameBoard td.hoverspecial.hover1:not(.hoverillegal)::after { background: var(--special-colour-4); }
#gameBoard td.hoverillegal::after { background: grey; }
/* Card list */
#cardList {
display: flex;
flex-wrap: wrap;
}
/* Game UI */
#gameSection:not([hidden]) {
display: flex;
}
#sidebarSection {
width: 24em;
display: flex;
flex-flow: column;
justify-content: space-between;
}
.playerBar {
border-left: 4px solid var(--colour);
padding-left: 10px;
}
.playerBar[data-index="0"] { --colour: var(--primary-colour-1); --special-colour: var(--special-colour-1); }
.playerBar[data-index="1"] { --colour: var(--primary-colour-2); --special-colour: var(--special-colour-2); }
.playerBar[data-index="2"] { --colour: var(--primary-colour-3); --special-colour: var(--special-colour-3); }
.playerBar[data-index="3"] { --colour: var(--primary-colour-4); --special-colour: var(--special-colour-4); }
.name {
font-size: x-large;
font-weight: bold;
margin: 0.5em 0;
}
.specialPoints div {
display: inline-block;
width: 1em;
height: 1em;
border: 1px solid;
text-align: center;
}
.playerBar .specialPoint {
color: transparent;
background: var(--special-colour);
}
.specialPoints div {
margin-right: 0.25em;
}
.specialPoint:nth-of-type(5n) {
margin-right: 0.5em;
}
.result {
font-family: 'Splatoon 1';
color: var(--colour);
text-transform: uppercase;
}
.result.win {
font-size: larger;
}
.playerStats {
margin: 0 1em;
background: rgba(64, 64, 64);
color: white;
display: flex;
justify-content: space-around;
text-align: center;
}
#gameSection:not(.gameEnded) .playerStats {
display: none;
}
.statValue {
font-family: 'Splatoon 1';
font-size: x-large;
line-height: 1.5em;
}
#midGameContainer {
height: 28em;
}
#resultContainer {
display: none;
}
/* Score bar */
#scoreSection {
display: grid;
grid-template-rows: 1fr 2fr 1fr;
place-items: center;
margin: 0 2em;
}
#turnNumberContainer {
width: 5em;
height: 5em;
background: radial-gradient(circle, rgb(128, 128, 128) 0%, rgb(128, 128, 128) 70%, rgba(0, 0, 0, 0) 70%);
font-family: 'Splatoon 1', sans-serif;
}
#turnNumberContainer p {
text-align: center;
margin: 0;
font-size: small;
}
#turnNumberLabel {
text-align: center;
font-size: x-large;
}
#turnNumberLabel.nowOrNever {
color: red;
}
#pointsContainers {
grid-row: 2;
}
.pointsContainer {
width: 5em;
height: 5em;
}
.pointsContainer {
position: relative;
display: flex;
font-family: 'Splatoon 1', sans-serif;
justify-content: center;
align-items: center;
}
.points {
font-size: xx-large;
}
.pointsToContainer {
position: absolute;
bottom: 0;
right: 0;
}
.pointsContainer[data-index="1"] {
--colour: var(--primary-colour-2);
}
.pointsContainer::before {
content: '';
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
opacity: 0.5;
z-index: -1;
background: radial-gradient(circle, var(--colour) 0%, var(--colour) 70%, rgba(0, 0, 0, 0) 70%);
}
.pointsContainer[data-index="0"] {
--colour: var(--primary-colour-1);
}
.pointsContainer {
color: var(--colour);
margin: 2em 0;
}
.pointsDelta {
position: absolute;
bottom: 0;
right: 1em;
}
/* Board section */
#boardSection {
position: relative;
}
#redrawModal {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: #000000C0;
}
#redrawModel:not([hidden]) {
display: flex;
align-items: center;
flex-flow: column;
}
#redrawBox {
/* display: flex; */
border: 1px solid grey;
width: 15em;
height: 10em;
background: black;
text-align: center;
margin-top: 12em;
}
#playsSection {
display: flex;
flex-flow: column;
justify-content: space-between;
}
/* Error UI */
#errorModal {
background: #00000080;
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
}
#errorModal:not([hidden]) {
display: flex;
justify-content: center;
align-items: center;
}
#errorModalBox {
width: 20em;
background: black;
height: 12em;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid gray;
}
.specialAttack, .specialAttack::after {
box-shadow: 0px 0 8px 4px var(--colour);
}
.specialAttack::after {
content: 'Special Attack!';
position: absolute;
left: -2em;
top: 5em;
right: -2em;
height: 2em;
background: var(--colour);
color: black;
text-align: center;
font-size: large;
border-radius: 0.5em;
animation: 3s forwards specialAttackAnimation;
}
@keyframes specialAttackAnimation {
from {
transform: scale(0);
opacity: 0;
}
15% {
transform: scale(0.9);
opacity: 1;
}
85% {
transform: scale(1.1);
opacity: 1;
}
to {
transform: scale(1.1);
opacity: 0;
}
}
.playContainer[data-index="0"] { --colour: var(--primary-colour-1); }
.playContainer[data-index="1"] { --colour: var(--primary-colour-2); }
.playContainer[data-index="2"] { --colour: var(--primary-colour-3); }
.playContainer[data-index="3"] { --colour: var(--primary-colour-4); }
.passLabel {
font-family: 'Splatoon 2', sans-serif;
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
display: flex;
background: #00000080;
justify-content: center;
align-items: center;
font-size: large;
}
/* Score section */

View File

@ -0,0 +1,14 @@
{
"files": [ "src/_header.ts" ],
"include": [ "src/**/*.ts" ],
"compilerOptions": {
"target": "ES2022",
"outFile": "build/tsbuild.js",
"noFallthroughCasesInSwitch": true,
"strict": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
}
}

View File

@ -0,0 +1,26 @@
{
"version": "0.2.0",
"configurations": [
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
// For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
"name": ".NET Core Launch (console)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/bin/Debug/net6.0/TableturfBattleServer.dll",
"args": [],
"cwd": "${workspaceFolder}",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
"console": "internalConsole",
"stopAtEntry": false
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}

View File

@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/TableturfBattleServer.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/TableturfBattleServer.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/TableturfBattleServer.csproj"
],
"problemMatcher": "$msCompile"
}
]
}

View File

@ -0,0 +1,62 @@
using Newtonsoft.Json;
namespace TableturfBattleServer;
public class Card {
[JsonProperty("number")]
public int Number { get; }
[JsonProperty("name")]
public string Name { get; }
[JsonProperty("rarity")]
public Rarity Rarity { get; }
[JsonProperty("specialCost")]
public int SpecialCost { get; }
[JsonIgnore]
public int Size { get; }
[JsonProperty("grid")]
private readonly Space[,] grid;
internal Card(int number, string name, Rarity rarity, int specialCost, Space[,] grid) {
this.Number = number;
this.Name = name ?? throw new ArgumentNullException(nameof(name));
this.Rarity = rarity;
this.SpecialCost = specialCost;
this.grid = grid ?? throw new ArgumentNullException(nameof(grid));
var size = 0;
if (grid.GetUpperBound(0) != 7 || grid.GetUpperBound(1) != 7)
throw new ArgumentException("Grid must be 8 × 8.", nameof(grid));
for (int y = 0; y < 8; y++) {
for (int x = 0; x < 8; x++) {
switch (grid[x, y]) {
case Space.Empty:
break;
case Space.Ink1:
case Space.SpecialInactive1:
size++;
break;
default:
throw new ArgumentException("Grid contains invalid values.", nameof(grid));
}
}
}
this.Size = size;
}
/// <summary>Returns the space in the specified position on the card grid when rotated in the specified manner.</summary>
/// <param name="x">The number of spaces right from the top left corner.</param>
/// <param name="y">The number of spaces down from the top left corner.</param>
/// <param name="rotation">The number of clockwise rotations.</param>
public Space GetSpace(int x, int y, int rotation) {
if (x is < 0 or >= 8)
throw new ArgumentOutOfRangeException(nameof(x));
if (y is < 0 or >= 8)
throw new ArgumentOutOfRangeException(nameof(y));
return (rotation & 3) switch {
0 => this.grid[x, y],
1 => this.grid[y, 7 - x],
2 => this.grid[7 - x, 7 - y],
_ => this.grid[7 - y, x],
};
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,17 @@
using Newtonsoft.Json;
namespace TableturfBattleServer;
public struct Colour {
[JsonProperty("r")]
public int R;
[JsonProperty("g")]
public int G;
[JsonProperty("b")]
public int B;
public Colour(int r, int g, int b) {
this.R = r;
this.G = g;
this.B = b;
}
}

View File

@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
namespace TableturfBattleServer;
public struct Error {
[JsonProperty("code")]
public string Code { get; }
[JsonProperty("description")]
public string Description { get; }
public Error(string code, string description) {
this.Code = code ?? throw new ArgumentNullException(nameof(code));
this.Description = description ?? throw new ArgumentNullException(nameof(description));
}
}

View File

@ -0,0 +1,272 @@
using System.Diagnostics.CodeAnalysis;
using System.Drawing;
using Newtonsoft.Json;
namespace TableturfBattleServer;
public class Game {
[JsonIgnore]
public Guid ID { get; } = Guid.NewGuid();
[JsonProperty("state")]
public GameState State { get; set; }
[JsonProperty("turnNumber")]
public int TurnNumber { get; set; }
[JsonProperty("players")]
public List<Player> Players { get; } = new(4);
[JsonProperty("board")]
public Space[,] Board { get; }
public Game(int boardWidth, int boardHeight) => this.Board = new Space[boardWidth, boardHeight];
public bool TryAddPlayer(Player player, out int playerIndex, out Error error) {
lock (this.Players) {
if (this.State != GameState.WaitingForPlayers) {
playerIndex = -1;
error = new("GameAlreadyStarted", "The game has already started.");
return false;
}
if (this.Players.Any(p => p.Token == player.Token)) {
playerIndex = -1;
error = new("PlayerAlreadyJoined", "You're already in the game.");
return false;
}
if (this.Players.Count >= 4) {
playerIndex = -1;
error = new("GameFull", "The game is full.");
return false;
}
playerIndex = this.Players.Count;
this.Players.Add(player);
error = default;
return true;
}
}
public bool GetPlayer(Guid clientToken, [MaybeNullWhen(false)] out int playerIndex, [MaybeNullWhen(false)] out Player player) {
for (var i = 0; i < this.Players.Count; i++) {
var player2 = this.Players[i];
if (player2.Token == clientToken) {
playerIndex = i;
player = player2;
return true;
}
}
playerIndex = -1;
player = null;
return false;
}
public bool CanPlay(int playerIndex, Card card, int x, int y, int rotation, bool isSpecialAttack) {
if (card is null) throw new ArgumentNullException(nameof(card));
if (isSpecialAttack && (this.Players[playerIndex].SpecialPoints < card.SpecialCost))
return false;
var isAnchored = false;
for (int dx = 0; dx < 8; dx++) {
for (int dy = 0; dy < 8; dy++) {
if (card.GetSpace(dx, dy, rotation) == Space.Empty)
continue;
var x2 = x + dx;
var y2 = y + dy;
if (x2 < 0 || x2 > this.Board.GetUpperBound(0)
|| y2 < 0 || y2 > this.Board.GetUpperBound(1))
return false; // Out of bounds.
switch (this.Board[x2, y2]) {
case Space.Wall: case Space.OutOfBounds:
return false;
case >= Space.SpecialInactive1:
return false; // Can't overlap special spaces ever.
case Space.Empty:
break;
default:
if (!isSpecialAttack) return false; // Can't overlap ink except with a special attack.
break;
}
if (!isAnchored) {
// A normal play must be adjacent to ink of the player's colour.
// A special attack must be adjacent to a special space of theirs.
for (int dy2 = -1; dy2 <= 1; dy2++) {
for (int dx2 = -1; dx2 <= 1; dx2++) {
if (dx2 == 0 && dy2 == 0) continue;
var x3 = x2 + dx2;
var y3 = y2 + dy2;
if (x3 < 0 || x3 > this.Board.GetUpperBound(0)
|| y3 < 0 || y3 > this.Board.GetUpperBound(1))
continue;
if (this.Board[x3, y3] >= (isSpecialAttack ? Space.SpecialInactive1 : Space.Ink1)
&& (((int) this.Board[x3, y3]) & 3) == playerIndex) {
isAnchored = true;
break;
}
}
}
}
}
}
return isAnchored;
}
internal void Tick() {
if (this.State == GameState.WaitingForPlayers && this.Players.Count >= 2) {
this.Players[0].Colour = new Colour(236, 249, 1);
this.Players[0].SpecialColour = new Colour(250, 158, 0);
this.Players[0].SpecialAccentColour = new Colour(249, 249, 31);
this.Players[1].Colour = new Colour(74, 92, 252);
this.Players[1].SpecialColour = new Colour(1, 237, 254);
this.Players[1].SpecialAccentColour = new Colour(213, 225, 225);
this.State = GameState.Preparing;
this.SendEvent("stateChange", this, true);
} else if (this.State == GameState.Preparing && this.Players.All(p => p.Deck != null)) {
// Draw cards.
var random = new Random();
foreach (var player in this.Players)
player.Shuffle(random);
this.State = GameState.Redraw;
this.SendEvent("stateChange", this, true);
} else if (this.State == GameState.Redraw && this.Players.All(p => p.Move != null)) {
var random = new Random();
foreach (var player in this.Players) {
if (player.Move!.IsSpecialAttack) {
player.Shuffle(random);
}
player.Move = null;
}
this.State = GameState.Ongoing;
this.TurnNumber = 1;
this.SendEvent("stateChange", this, true);
} else if (this.State == GameState.Ongoing && this.Players.All(p => p.Move != null)) {
var moves = new Move?[this.Players.Count];
var placements = new List<Placement>();
var specialSpacesActivated = new List<Point>();
// Place the ink.
(Placement placement, int cardSize)? placementData = null;
foreach (var i in Enumerable.Range(0, this.Players.Count).Where(i => this.Players[i] != null).OrderByDescending(i => this.Players[i]!.Move!.Card.Size)) {
var player = this.Players[i];
var move = player.Move!;
moves[i] = move;
player.CardsUsed.Add(move.Card.Number);
if (move.IsPass) {
player.Passes++;
player.SpecialPoints++;
player.TotalSpecialPoints++;
} else {
if (move.IsSpecialAttack)
player.SpecialPoints -= move.Card.SpecialCost;
if (placementData == null || move.Card.Size != placementData.Value.cardSize) {
if (placementData != null)
placements.Add(placementData.Value.placement);
placementData = (new(), move.Card.Size);
}
var placement = placementData.Value.placement;
placement.Players.Add(i);
for (int dy = 0; dy < 8; dy++) {
var y = move.Y + dy;
for (int dx = 0; dx < 8; dx++) {
var x = move.X + dx;
var point = new Point(x, y);
switch (move.Card.GetSpace(dx, dy, move.Rotation)) {
case Space.Ink1:
if (placement.SpacesAffected.TryGetValue(point, out var space)) {
if (space < Space.SpecialInactive1) {
// Two ink spaces overlapped; create a wall there.
this.Board[x, y] = placement.SpacesAffected[point] = Space.Wall;
}
} else {
if (this.Board[x, y] < Space.SpecialInactive1) // Ink spaces can't overlap special spaces from larger cards.
this.Board[x, y] = placement.SpacesAffected[point] = Space.Ink1 | (Space) i;
}
break;
case Space.SpecialInactive1:
if (placement.SpacesAffected.TryGetValue(point, out space) && space >= Space.SpecialInactive1) {
// Two special spaces overlapped; create a wall there.
this.Board[x, y] = placement.SpacesAffected[point] = Space.Wall;
} else {
// If a special space overlaps an ink space, overwrite it.
this.Board[x, y] = placement.SpacesAffected[point] = Space.SpecialInactive1 | (Space) i;
}
break;
}
}
}
}
}
if (placementData != null)
placements.Add(placementData.Value.placement);
// Activate special spaces.
for (int y = 0; y < this.Board.GetLength(1); y++) {
for (int x = 0; x < this.Board.GetLength(0); x++) {
if ((this.Board[x, y] & Space.SpecialActive1) == Space.SpecialInactive1) {
var anyEmptySpace = false;
for (int dy = -1; !anyEmptySpace && dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
var x2 = x + dx;
var y2 = y + dy;
if (x2 >= 0 && x2 < this.Board.GetLength(0) && y2 >= 0 && y2 < this.Board.GetLength(1)
&& this.Board[x2, y2] == Space.Empty) {
anyEmptySpace = true;
break;
}
}
}
if (!anyEmptySpace) {
var player = this.Players[(int) this.Board[x, y] & 3]!;
this.Board[x, y] |= Space.SpecialActive1;
player.SpecialPoints++;
player.TotalSpecialPoints++;
specialSpacesActivated.Add(new(x, y));
}
}
}
}
if (this.TurnNumber == 12) {
this.State = GameState.Ended;
this.SendEvent("gameEnd", new { game = this, moves, placements, specialSpacesActivated }, true);
foreach (var player in this.Players) {
player.Move = null;
}
} else {
this.TurnNumber++;
// Draw cards.
foreach (var player in this.Players) {
var index = player.GetHandIndex(player.Move!.Card.Number);
var draw = player.drawOrder![this.TurnNumber + 2];
player.Hand![index] = player.Deck![draw];
player.Move = null;
}
this.SendEvent("turn", new { game = this, moves, placements, specialSpacesActivated }, true);
}
}
}
internal void SendPlayerReadyEvent(int playerIndex) => this.SendEvent("playerReady", new { playerIndex }, false);
internal void SendEvent(string eventType, object? data, bool includePlayerData) {
foreach (var session in Program.httpServer.WebSocketServices.Hosts.First().Sessions.Sessions) {
if (session is TableturfWebSocketBehaviour behaviour && behaviour.GameID == this.ID) {
if (includePlayerData) {
object? playerData = null;
for (int i = 0; i < this.Players.Count; i++) {
var player = this.Players[i];
if (player.Token == behaviour.ClientToken) {
playerData = new { playerIndex = i, hand = player.Hand, deck = player.Deck, move = player.Move, cardsUsed = player.CardsUsed };
break;
}
}
behaviour.SendInternal(JsonConvert.SerializeObject(new { @event = eventType, data, playerData }));
} else {
behaviour.SendInternal(JsonConvert.SerializeObject(new { @event = eventType, data }));
}
}
}
}
}

View File

@ -0,0 +1,9 @@
namespace TableturfBattleServer;
public enum GameState {
WaitingForPlayers,
Preparing,
Redraw,
Ongoing,
Ended
}

View File

@ -0,0 +1,37 @@
using System.ComponentModel;
using Newtonsoft.Json;
namespace TableturfBattleServer;
public class Move {
[JsonProperty("card")]
public Card Card { get; }
[JsonProperty("isPass")]
public bool IsPass { get; }
[JsonProperty("x")]
public int X { get; }
[JsonProperty("y")]
public int Y { get; }
[JsonProperty("rotation")]
public int Rotation { get; }
[JsonProperty("isSpecialAttack")]
public bool IsSpecialAttack { get; }
public Move(Card card, bool isPass, int x, int y, int rotation, bool isSpecialAttack) {
this.Card = card ?? throw new ArgumentNullException(nameof(card));
this.IsPass = isPass;
this.X = x;
this.Y = y;
this.Rotation = rotation;
this.IsSpecialAttack = isSpecialAttack;
}
[EditorBrowsable(EditorBrowsableState.Never)]
public bool ShouldSerializeX() => !this.IsPass;
[EditorBrowsable(EditorBrowsableState.Never)]
public bool ShouldSerializeY() => !this.IsPass;
[EditorBrowsable(EditorBrowsableState.Never)]
public bool ShouldSerializeRotation() => !this.IsPass;
[EditorBrowsable(EditorBrowsableState.Never)]
public bool ShouldSerializeIsSpecialAttack() => !this.IsPass;
}

View File

@ -0,0 +1,20 @@
using Newtonsoft.Json;
namespace TableturfBattleServer;
public class Placement {
[JsonProperty("players")]
public List<int> Players { get; } = new();
[JsonProperty("spacesAffected"), JsonConverter(typeof(SpacesAffectedDictionaryConverter))]
public Dictionary<Point, Space> SpacesAffected { get; } = new();
internal class SpacesAffectedDictionaryConverter : JsonConverter<Dictionary<Point, Space>> {
public override Dictionary<Point, Space>? ReadJson(JsonReader reader, Type objectType, Dictionary<Point, Space>? existingValue, bool hasExistingValue, JsonSerializer serializer) {
var list = serializer.Deserialize<List<(Point space, Space newState)>>(reader);
return list?.ToDictionary(o => o.space, o => o.newState);
}
public override void WriteJson(JsonWriter writer, Dictionary<Point, Space>? value, JsonSerializer serializer) {
serializer.Serialize(writer, value?.Select(e => new { space = e.Key, newState = e.Value }));
}
}
}

View File

@ -0,0 +1,68 @@
using System.Diagnostics.CodeAnalysis;
using Newtonsoft.Json;
namespace TableturfBattleServer;
public class Player {
[JsonProperty("name")]
public string Name { get; }
[JsonIgnore]
public Guid Token { get; }
[JsonProperty("colour")]
public Colour Colour { get; set; }
[JsonProperty("specialColour")]
public Colour SpecialColour { get; set; }
[JsonProperty("specialAccentColour")]
public Colour SpecialAccentColour { get; set; }
[JsonProperty("specialPoints")]
public int SpecialPoints { get; set; }
[JsonProperty("totalSpecialPoints")]
public int TotalSpecialPoints { get; set; }
[JsonProperty("passes")]
public int Passes { get; set; }
[JsonProperty("isReady")]
public bool IsReady => this.Move != null || (this.Deck != null && this.Hand == null);
[JsonIgnore]
internal Card[]? Deck;
[JsonIgnore]
internal readonly List<int> CardsUsed = new(12);
[JsonIgnore]
internal Card[]? Hand;
[JsonIgnore]
internal Move? Move;
[JsonIgnore]
internal int[]? drawOrder;
public Player(string name, Guid token) {
this.Name = name ?? throw new ArgumentNullException(nameof(name));
this.Token = token;
}
[MemberNotNull(nameof(drawOrder))]
internal void Shuffle(Random random) {
this.drawOrder = new int[15];
for (int i = 0; i < 15; i++) this.drawOrder[i] = i;
for (int i = 14; i > 0; i--) {
var j = random.Next(i);
(this.drawOrder[i], this.drawOrder[j]) = (this.drawOrder[j], this.drawOrder[i]);
}
if (this.Deck != null) {
this.Hand = new Card[4];
for (int i = 0; i < 4; i++) {
this.Hand[i] = this.Deck[this.drawOrder[i]];
}
}
}
internal int GetHandIndex(int cardNumber) {
if (this.Hand != null) {
for (int i = 0; i < 4; i++) {
if (this.Hand[i].Number == cardNumber) return i;
}
}
return -1;
}
}

View File

@ -0,0 +1,14 @@
using Newtonsoft.Json;
namespace TableturfBattleServer;
public struct Point {
[JsonProperty("x")]
public int X;
[JsonProperty("y")]
public int Y;
public Point(int x, int y) {
this.X = x;
this.Y = y;
}
}

View File

@ -0,0 +1,374 @@
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using System.Timers;
using System.Web;
using Newtonsoft.Json;
using WebSocketSharp.Server;
using Timer = System.Timers.Timer;
namespace TableturfBattleServer;
internal class Program {
internal static HttpServer httpServer = new(IPAddress.Loopback, 3333);
internal static Dictionary<Guid, Game> games = new();
internal static readonly Timer timer = new(500);
private static readonly List<Guid> gameIdsToRemove = new();
internal static void Main() {
timer.Elapsed += Timer_Elapsed;
httpServer.AddWebSocketService<TableturfWebSocketBehaviour>("/api/websocket");
httpServer.OnGet += HttpServer_OnRequest;
httpServer.OnPost += HttpServer_OnRequest;
httpServer.Start();
Console.WriteLine($"Listening on http://{httpServer.Address}:{httpServer.Port}");
Thread.Sleep(Timeout.Infinite);
}
private static void Timer_Elapsed(object? sender, ElapsedEventArgs e) {
lock (games) {
foreach (var (id, game) in games) {
lock (game.Players) {
game.Tick();
}
}
}
}
private static void HttpServer_OnRequest(object? sender, HttpRequestEventArgs e) {
e.Response.AppendHeader("Access-Control-Allow-Origin", "*");
if (e.Request.RawUrl == "/api/games/new") {
if (e.Request.HttpMethod != "POST") {
e.Response.StatusCode = (int) HttpStatusCode.MethodNotAllowed;
e.Response.AddHeader("Allow", "POST");
} else if (e.Request.ContentLength64 >= 65536) {
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
} else {
try {
var d = DecodeFormData(e.Request.InputStream);
Guid clientToken;
if (!d.TryGetValue("name", out var name)) {
SetErrorResponse(e.Response, (int) HttpStatusCode.UnprocessableEntity, new("InvalidName", "Missing name."));
return;
}
if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") {
if (!Guid.TryParse(tokenString, out clientToken)) {
SetErrorResponse(e.Response, (int) HttpStatusCode.UnprocessableEntity, new("InvalidClientToken", "Invalid client token."));
return;
}
} else
clientToken = Guid.NewGuid();
var game = new Game(9, 26);
game.Board[4, 4] = Space.SpecialInactive2;
game.Board[4, 21] = Space.SpecialInactive1;
game.Players[0] = new(name, clientToken);
games.Add(game.ID, game);
SetResponse(e.Response, (int) HttpStatusCode.OK, "application/json", JsonConvert.SerializeObject(new { gameID = game.ID, clientToken }));
} catch (ArgumentException) {
e.Response.StatusCode = (int) HttpStatusCode.BadRequest;
}
}
} else if (e.Request.RawUrl == "/api/cards") {
if (e.Request.HttpMethod is not ("GET" or "HEAD")) {
e.Response.StatusCode = (int) HttpStatusCode.MethodNotAllowed;
e.Response.AddHeader("Allow", "GET, HEAD");
return;
}
e.Response.AppendHeader("Cache-Control", "max-age=86400");
e.Response.AppendHeader("ETag", CardDatabase.Version.ToString());
if (e.Response.Headers["If-None-Match"] == CardDatabase.Version.ToString()) {
e.Response.StatusCode = (int) HttpStatusCode.NotModified;
} else {
SetResponse(e.Response, (int) HttpStatusCode.OK, "application/json", CardDatabase.JSON);
}
} else {
var m = Regex.Match(e.Request.RawUrl, @"^/api/games/([\w-]+)(?:/(\w+)(?:/([\w-]+))?)?$", RegexOptions.Compiled);
if (m.Success) {
if (!Guid.TryParse(m.Groups[1].Value, out var gameID)) {
SetResponse(e.Response, 400, "text/plain", "Invalid game ID");
return;
}
lock (games) {
if (!games.TryGetValue(gameID, out var game)) {
SetResponse(e.Response, 404, "text/plain", "Game not found");
return;
}
lock (game.Players) {
switch (m.Groups[2].Value) {
case "": {
if (e.Request.HttpMethod is not ("GET" or "HEAD")) {
e.Response.StatusCode = (int) HttpStatusCode.MethodNotAllowed;
e.Response.AddHeader("Allow", "GET, HEAD");
return;
}
SetResponse(e.Response, (int) HttpStatusCode.OK, "application/json", JsonConvert.SerializeObject(game));
break;
}
case "playerData": {
if (e.Request.HttpMethod is not ("GET" or "HEAD")) {
e.Response.StatusCode = (int) HttpStatusCode.MethodNotAllowed;
e.Response.AddHeader("Allow", "GET, HEAD");
return;
}
if (!Guid.TryParse(m.Groups[3].Value, out var clientToken))
clientToken = Guid.Empty;
SetResponse(e.Response, (int) HttpStatusCode.OK, "application/json", JsonConvert.SerializeObject(new {
game,
playerData = game.GetPlayer(clientToken, out var playerIndex, out var player)
? new { playerIndex, hand = player.Hand, deck = player.Deck, cardsUsed = player.CardsUsed, move = player.Move }
: null
}));
break;
}
case "join": {
if (e.Request.HttpMethod != "POST") {
e.Response.StatusCode = (int) HttpStatusCode.MethodNotAllowed;
e.Response.AddHeader("Allow", "POST");
} else if (e.Request.ContentLength64 >= 65536) {
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
} else {
try {
var d = DecodeFormData(e.Request.InputStream);
Guid clientToken;
if (!d.TryGetValue("name", out var name)) {
SetErrorResponse(e.Response, (int) HttpStatusCode.UnprocessableEntity, new("InvalidName", "Missing name."));
return;
}
if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") {
if (!Guid.TryParse(tokenString, out clientToken)) {
SetErrorResponse(e.Response, (int) HttpStatusCode.BadRequest, new("InvalidClientToken", "Invalid client token."));
return;
}
} else
clientToken = Guid.NewGuid();
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
if (game.State != GameState.WaitingForPlayers) {
SetErrorResponse(e.Response, (int) HttpStatusCode.Gone, new("GameAlreadyStarted", "The game has already started."));
return;
}
player = new Player(name, clientToken);
if (!game.TryAddPlayer(player, out playerIndex, out var error)) {
SetErrorResponse(e.Response, error.Code == "GameAlreadyStarted" ? (int) HttpStatusCode.Gone : 422, error);
return;
}
}
// If they're already in the game, resend the original join response instead of an error.
SetResponse(e.Response, (int) HttpStatusCode.OK, "application/json", JsonConvert.SerializeObject(new { playerIndex, clientToken }));
game.SendEvent("join", new { playerIndex, player }, false);
timer.Start();
} catch (ArgumentException) {
e.Response.StatusCode = (int) HttpStatusCode.BadRequest;
}
}
break;
}
case "chooseDeck": {
if (e.Request.HttpMethod != "POST") {
e.Response.StatusCode = (int) HttpStatusCode.MethodNotAllowed;
e.Response.AddHeader("Allow", "POST");
} else if (e.Request.ContentLength64 >= 65536) {
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
} else {
try {
var d = DecodeFormData(e.Request.InputStream);
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
SetErrorResponse(e.Response, (int) HttpStatusCode.BadRequest, new("InvalidClientToken", "Invalid client token."));
return;
}
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
SetErrorResponse(e.Response, (int) HttpStatusCode.UnprocessableEntity, new("NotInGame", "You're not in the game."));
return;
}
if (player.Deck != null) {
SetErrorResponse(e.Response, (int) HttpStatusCode.Conflict, new("DeckAlreadyChosen", "You've already chosen a deck."));
return;
}
if (!d.TryGetValue("deckName", out var deckName)) {
SetErrorResponse(e.Response, (int) HttpStatusCode.BadRequest, new("InvalidDeckName", "Missing deck name."));
return;
}
if (!d.TryGetValue("deckCards", out var deckString)) {
SetErrorResponse(e.Response, (int) HttpStatusCode.BadRequest, new("InvalidDeckCards", "Missing deck cards."));
return;
}
var array = deckString.Split(new[] { ',', '+', ' ' }, 15);
if (array.Length != 15) {
SetErrorResponse(e.Response, (int) HttpStatusCode.UnprocessableEntity, new("InvalidDeckCards", "Invalid deck list."));
return;
}
var cards = new int[15];
for (int i = 0; i < 15; i++) {
if (!int.TryParse(array[i], out var cardNumber) || cardNumber < 0 || cardNumber > CardDatabase.Cards.Count) {
SetErrorResponse(e.Response, (int) HttpStatusCode.UnprocessableEntity, new("InvalidDeckCards", "Invalid deck list."));
return;
}
if (Array.IndexOf(cards, cardNumber, 0, i) >= 0) {
SetErrorResponse(e.Response, (int) HttpStatusCode.UnprocessableEntity, new("InvalidDeckCards", "Deck cannot have duplicates."));
return;
}
cards[i] = cardNumber;
}
player.Deck = cards.Select(CardDatabase.GetCard).ToArray();
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
game.SendPlayerReadyEvent(playerIndex);
timer.Start();
} catch (ArgumentException) {
e.Response.StatusCode = (int) HttpStatusCode.BadRequest;
}
}
break;
}
case "play": {
if (e.Request.HttpMethod != "POST") {
e.Response.StatusCode = (int) HttpStatusCode.MethodNotAllowed;
e.Response.AddHeader("Allow", "POST");
} else if (e.Request.ContentLength64 >= 65536) {
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
} else {
try {
var d = DecodeFormData(e.Request.InputStream);
Guid clientToken;
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out clientToken)) {
SetResponse(e.Response, (int) HttpStatusCode.BadRequest, "text/plain", "Invalid client token");
return;
}
if (game.State != GameState.Ongoing) {
SetResponse(e.Response, (int) HttpStatusCode.Gone, "text/plain", "You can't do that in this game state.");
return;
}
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
SetErrorResponse(e.Response, (int) HttpStatusCode.UnprocessableEntity, new("NotInGame", "You're not in the game."));
return;
}
if (player!.Move != null) {
SetResponse(e.Response, (int) HttpStatusCode.Conflict, "text/plain", "You've already chosen a move.");
return;
}
if (!d.TryGetValue("cardNumber", out var cardNumberStr) || !int.TryParse(cardNumberStr, out var cardNumber)) {
SetResponse(e.Response, (int) HttpStatusCode.UnprocessableEntity, "text/plain", "Missing or invalid card number");
return;
}
var handIndex = player.GetHandIndex(cardNumber);
if (handIndex < 0) {
SetResponse(e.Response, (int) HttpStatusCode.UnprocessableEntity, "text/plain", "You don't have that card");
return;
}
var card = player.Hand![handIndex];
if (d.TryGetValue("isPass", out var isPassStr) && isPassStr.ToLower() is not ("false" or "0")) {
player.Move = new(card, true, 0, 0, 0, false);
} else {
var isSpecialAttack = d.TryGetValue("isSpecialAttack", out var isSpecialAttackStr) && isSpecialAttackStr.ToLower() is not ("false" or "0");
if (!d.TryGetValue("x", out var xs) || !int.TryParse(xs, out var x)
|| !d.TryGetValue("y", out var ys) || !int.TryParse(ys, out var y)
|| !d.TryGetValue("r", out var rs) || !int.TryParse(rs, out var r)) {
SetResponse(e.Response, (int) HttpStatusCode.BadRequest, "text/plain", "Missing or invalid coordinates");
return;
}
r &= 3;
if (!game.CanPlay(playerIndex, card, x, y, r, isSpecialAttack)) {
SetResponse(e.Response, (int) HttpStatusCode.UnprocessableEntity, "text/plain", "Illegal move");
return;
}
player.Move = new(card, false, x, y, r, isSpecialAttack);
}
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
game.SendPlayerReadyEvent(playerIndex);
timer.Start();
} catch (ArgumentException) {
e.Response.StatusCode = (int) HttpStatusCode.BadRequest;
}
}
break;
}
case "redraw": {
if (e.Request.HttpMethod != "POST") {
e.Response.StatusCode = (int) HttpStatusCode.MethodNotAllowed;
e.Response.AddHeader("Allow", "POST");
} else if (e.Request.ContentLength64 >= 65536) {
e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge;
} else {
try {
if (game.State != GameState.Redraw) {
SetResponse(e.Response, (int) HttpStatusCode.Gone, "text/plain", "You can't do that in this game state.");
return;
}
var d = DecodeFormData(e.Request.InputStream);
if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) {
SetResponse(e.Response, (int) HttpStatusCode.BadRequest, "text/plain", "Invalid client token");
return;
}
if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) {
SetResponse(e.Response, (int) HttpStatusCode.UnprocessableEntity, "text/plain", "You're not in the game.");
return;
}
if (player.Move != null) {
SetResponse(e.Response, (int) HttpStatusCode.Conflict, "text/plain", "You've already chosen a move.");
return;
}
var redraw = d.TryGetValue("redraw", out var redrawStr) && redrawStr.ToLower() is not ("false" or "0");
player.Move = new(player.Hand![0], false, 0, 0, 0, redraw);
e.Response.StatusCode = (int) HttpStatusCode.NoContent;
game.SendPlayerReadyEvent(playerIndex);
timer.Start();
} catch (ArgumentException) {
e.Response.StatusCode = (int) HttpStatusCode.BadRequest;
}
}
break;
}
default:
SetResponse(e.Response, (int) HttpStatusCode.NotFound, "text/plain", "Endpoint not found");
break;
}
}
}
} else {
SetResponse(e.Response, (int) HttpStatusCode.NotFound, "text/plain", "Endpoint not found");
}
}
}
private static void SetErrorResponse(WebSocketSharp.Net.HttpListenerResponse response, int statusCode, Error error) {
var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(error));
SetResponse(response, statusCode, "application/json", bytes);
}
private static void SetResponse(WebSocketSharp.Net.HttpListenerResponse response, int statusCode, string contentType, string content) {
var bytes = Encoding.UTF8.GetBytes(content);
SetResponse(response, statusCode, contentType, bytes);
}
private static void SetResponse(WebSocketSharp.Net.HttpListenerResponse response, int statusCode, string contentType, byte[] content) {
response.StatusCode = statusCode;
response.ContentType = contentType;
response.ContentLength64 = content.Length;
response.Close(content, true);
}
private static Dictionary<string, string> DecodeFormData(Stream stream) {
using var reader = new StreamReader(stream);
var s = reader.ReadToEnd();
return s != ""
? s.Split(new[] { '&' }).Select(s => s.Split('=')).Select(a => a.Length == 2 ? a : throw new ArgumentException("Invalid form data"))
.ToDictionary(a => HttpUtility.UrlDecode(a[0]), a => HttpUtility.UrlDecode(a[1]))
: new();
}
}

View File

@ -0,0 +1,7 @@
namespace TableturfBattleServer;
public enum Rarity {
Common,
Rare,
Fresh
}

View File

@ -0,0 +1,18 @@
namespace TableturfBattleServer;
public enum Space {
Empty = 0,
Wall = 1,
OutOfBounds = 2,
Ink1 = 4,
Ink2 = 5,
Ink3 = 6,
Ink4 = 7,
SpecialInactive1 = 8,
SpecialInactive2 = 9,
SpecialInactive3 = 10,
SpecialInactive4 = 11,
SpecialActive1 = 12,
SpecialActive2 = 13,
SpecialActive3 = 14,
SpecialActive4 = 15
}

View File

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackageProjectUrl>https://github.com/AndrioCelos/TableturfBattleApp</PackageProjectUrl>
<RepositoryUrl>https://github.com/AndrioCelos/TableturfBattleApp</RepositoryUrl>
<Version>0.0.0.0</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="WebSocketSharp-netstandard" Version="1.0.1" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,19 @@
using System.Web;
using WebSocketSharp.Server;
internal class TableturfWebSocketBehaviour : WebSocketBehavior {
public Guid GameID { get; set; }
public Guid ClientToken { get; set; }
protected override void OnOpen() {
var args = this.Context.RequestUri.Query[1..].Split('&').Select(s => s.Split('=', 2)).Where(a => a.Length == 2)
.ToDictionary(a => HttpUtility.UrlDecode(a[0]), a => HttpUtility.UrlDecode(a[1]));
if (args.TryGetValue("gameID", out var gameIDString) && Guid.TryParse(gameIDString, out var gameID))
this.GameID = gameID;
if (args.TryGetValue("clientToken", out var clientTokenString) && Guid.TryParse(clientTokenString, out var clientToken))
this.ClientToken = clientToken;
}
internal void SendInternal(string data) => this.Send(data);
}

21
license.md Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Andrea Giannone
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

7
readme.md Normal file
View File

@ -0,0 +1,7 @@
# Tableturf Battle
This is a Web application and backend that simulates the [Tableturf Battle](https://splatoonwiki.org/wiki/Tableturf_Battle) minigame from [_Splatoon 3_](https://splatoon.nintendo.com/).
This application is under development and should be considered an early access release.
_Splatoon_ is © Nintendo. This is a fan project and is not affiliated with Nintendo. All product names, logos, and brands are property of their respective owners.