commit 1f44ab6d56e19a59b7bdf75e893ec2432f78e3c6 Author: Andrio Celos Date: Sat Oct 1 16:00:50 2022 +1000 Initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..06a8157 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..971b55e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/TableturfBattle.sln b/TableturfBattle.sln new file mode 100644 index 0000000..804416a --- /dev/null +++ b/TableturfBattle.sln @@ -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 diff --git a/TableturfBattleClient/.vscode/launch.json b/TableturfBattleClient/.vscode/launch.json new file mode 100644 index 0000000..1ddb3ef --- /dev/null +++ b/TableturfBattleClient/.vscode/launch.json @@ -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", + } + } + ] +} diff --git a/TableturfBattleClient/.vscode/tasks.json b/TableturfBattleClient/.vscode/tasks.json new file mode 100644 index 0000000..2ecc79f --- /dev/null +++ b/TableturfBattleClient/.vscode/tasks.json @@ -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" + } + ] +} diff --git a/TableturfBattleClient/index.html b/TableturfBattleClient/index.html new file mode 100644 index 0000000..e263e22 --- /dev/null +++ b/TableturfBattleClient/index.html @@ -0,0 +1,110 @@ + + + + + Tableturf Battle + + + +
This application requires JavaScript.
+
Loading game data...
+ + + + + + + + diff --git a/TableturfBattleClient/src/Board.ts b/TableturfBattleClient/src/Board.ts new file mode 100644 index 0000000..e7e4a43 --- /dev/null +++ b/TableturfBattleClient/src/Board.ts @@ -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; + } +} diff --git a/TableturfBattleClient/src/Card.ts b/TableturfBattleClient/src/Card.ts new file mode 100644 index 0000000..1e0c1a7 --- /dev/null +++ b/TableturfBattleClient/src/Card.ts @@ -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]; + } + } +} diff --git a/TableturfBattleClient/src/CardButton.ts b/TableturfBattleClient/src/CardButton.ts new file mode 100644 index 0000000..09b16ee --- /dev/null +++ b/TableturfBattleClient/src/CardButton.ts @@ -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'); + } +} diff --git a/TableturfBattleClient/src/CardDatabase.ts b/TableturfBattleClient/src/CardDatabase.ts new file mode 100644 index 0000000..38fae53 --- /dev/null +++ b/TableturfBattleClient/src/CardDatabase.ts @@ -0,0 +1,31 @@ +const cardDatabase = { + cards: null as Card[] | null, + loadAsync() { + return new Promise((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(); + }); + } +} diff --git a/TableturfBattleClient/src/GameState.ts b/TableturfBattleClient/src/GameState.ts new file mode 100644 index 0000000..57ba924 --- /dev/null +++ b/TableturfBattleClient/src/GameState.ts @@ -0,0 +1,7 @@ +enum GameState { + WaitingForPlayers, + Preparing, + Redraw, + Ongoing, + Ended +} diff --git a/TableturfBattleClient/src/GameVariables.ts b/TableturfBattleClient/src/GameVariables.ts new file mode 100644 index 0000000..88604fa --- /dev/null +++ b/TableturfBattleClient/src/GameVariables.ts @@ -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 ]; diff --git a/TableturfBattleClient/src/Move.ts b/TableturfBattleClient/src/Move.ts new file mode 100644 index 0000000..cc765f3 --- /dev/null +++ b/TableturfBattleClient/src/Move.ts @@ -0,0 +1,12 @@ +interface Move { + card: Card; + isPass: boolean; +} + +interface PlayMove extends Move { + isPass: true; + x: number; + y: number; + rotation: number; + isSpecialAttack: boolean; +} diff --git a/TableturfBattleClient/src/Pages/GamePage.ts b/TableturfBattleClient/src/Pages/GamePage.ts new file mode 100644 index 0000000..ba2d02c --- /dev/null +++ b/TableturfBattleClient/src/Pages/GamePage.ts @@ -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(); + 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; + } +}; diff --git a/TableturfBattleClient/src/Pages/LobbyPage.ts b/TableturfBattleClient/src/Pages/LobbyPage.ts new file mode 100644 index 0000000..ce5f0f8 --- /dev/null +++ b/TableturfBattleClient/src/Pages/LobbyPage.ts @@ -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()); +}); diff --git a/TableturfBattleClient/src/Pages/PreGamePage.ts b/TableturfBattleClient/src/Pages/PreGamePage.ts new file mode 100644 index 0000000..15f6e37 --- /dev/null +++ b/TableturfBattleClient/src/Pages/PreGamePage.ts @@ -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) + } +} diff --git a/TableturfBattleClient/src/Player.ts b/TableturfBattleClient/src/Player.ts new file mode 100644 index 0000000..903a7d7 --- /dev/null +++ b/TableturfBattleClient/src/Player.ts @@ -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; +} diff --git a/TableturfBattleClient/src/PlayerBar.ts b/TableturfBattleClient/src/PlayerBar.ts new file mode 100644 index 0000000..80b3d49 --- /dev/null +++ b/TableturfBattleClient/src/PlayerBar.ts @@ -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(); + } + } +} diff --git a/TableturfBattleClient/src/PlayerData.ts b/TableturfBattleClient/src/PlayerData.ts new file mode 100644 index 0000000..b5a1843 --- /dev/null +++ b/TableturfBattleClient/src/PlayerData.ts @@ -0,0 +1,7 @@ +interface PlayerData { + playerIndex: number; + hand: Card[] | null; + deck: Card[] | null; + cardsUsed: number[]; + move: Move | null; +} diff --git a/TableturfBattleClient/src/Rarity.ts b/TableturfBattleClient/src/Rarity.ts new file mode 100644 index 0000000..6cba30b --- /dev/null +++ b/TableturfBattleClient/src/Rarity.ts @@ -0,0 +1,5 @@ +enum Rarity { + Common, + Rare, + Fresh +} diff --git a/TableturfBattleClient/src/Space.ts b/TableturfBattleClient/src/Space.ts new file mode 100644 index 0000000..e825dbd --- /dev/null +++ b/TableturfBattleClient/src/Space.ts @@ -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 +} diff --git a/TableturfBattleClient/src/TurnNumberLabel.ts b/TableturfBattleClient/src/TurnNumberLabel.ts new file mode 100644 index 0000000..716d048 --- /dev/null +++ b/TableturfBattleClient/src/TurnNumberLabel.ts @@ -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'); + } + } +} diff --git a/TableturfBattleClient/src/_header.ts b/TableturfBattleClient/src/_header.ts new file mode 100644 index 0000000..d109806 --- /dev/null +++ b/TableturfBattleClient/src/_header.ts @@ -0,0 +1 @@ +// This file was generated using TypeScript. diff --git a/TableturfBattleClient/src/app.ts b/TableturfBattleClient/src/app.ts new file mode 100644 index 0000000..2110732 --- /dev/null +++ b/TableturfBattleClient/src/app.ts @@ -0,0 +1,103 @@ +/// + +function delay(ms: number) { return new Promise(resolve => setTimeout(() => resolve(null), ms)); } + +// Sections +const sections = new Map(); +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."); +} diff --git a/TableturfBattleClient/tableturf.css b/TableturfBattleClient/tableturf.css new file mode 100644 index 0000000..39b6343 --- /dev/null +++ b/TableturfBattleClient/tableturf.css @@ -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 */ diff --git a/TableturfBattleClient/tsconfig.json b/TableturfBattleClient/tsconfig.json new file mode 100644 index 0000000..a9d7272 --- /dev/null +++ b/TableturfBattleClient/tsconfig.json @@ -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, + + } +} diff --git a/TableturfBattleServer/.vscode/launch.json b/TableturfBattleServer/.vscode/launch.json new file mode 100644 index 0000000..3b4da5c --- /dev/null +++ b/TableturfBattleServer/.vscode/launch.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/TableturfBattleServer/.vscode/tasks.json b/TableturfBattleServer/.vscode/tasks.json new file mode 100644 index 0000000..ca29f35 --- /dev/null +++ b/TableturfBattleServer/.vscode/tasks.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/TableturfBattleServer/Card.cs b/TableturfBattleServer/Card.cs new file mode 100644 index 0000000..ae587b8 --- /dev/null +++ b/TableturfBattleServer/Card.cs @@ -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; + } + + /// Returns the space in the specified position on the card grid when rotated in the specified manner. + /// The number of spaces right from the top left corner. + /// The number of spaces down from the top left corner. + /// The number of clockwise rotations. + 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], + }; + } +} diff --git a/TableturfBattleServer/CardDatabase.cs b/TableturfBattleServer/CardDatabase.cs new file mode 100644 index 0000000..489f917 --- /dev/null +++ b/TableturfBattleServer/CardDatabase.cs @@ -0,0 +1,1646 @@ +using System.Collections.ObjectModel; + +using Newtonsoft.Json; + +namespace TableturfBattleServer; +public static class CardDatabase { + private const Space I = Space.Ink1; + private const Space S = Space.SpecialInactive1; + + private static readonly Card[] cards = new Card[] { + new( 1, "Hero Shot", Rarity.Fresh, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, I, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, S, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 2, "Sploosh-o-matic", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, S, I, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 3, "Splattershot Jr.", Rarity.Common, 2, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, S, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 4, "Splash-o-matic", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, S, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 5, "Aerospray MG", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, S, I, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 6, "Splattershot", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, S, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 7, ".52 Gal", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, S, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 8, "N-ZAP '85", Rarity.Common, 2, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, S, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 9, "Splattershot Pro", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, S, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 10, ".96 Gal", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, S, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 11, "Jet Squelcher", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, S, 0, I, 0, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 12, "Luna Blaster", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, S, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 13, "Blaster", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, S, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 14, "Range Blaster", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, S, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 15, "Clash Blaster", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, S, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 16, "Rapid Blaster", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, S, I, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 17, "Rapid Blaster Pro", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, S, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 18, "L-3 Nozzlenose", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, S, I, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 19, "H-3 Nozzlenose", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, S, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 20, "Squeezer", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, S, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 21, "Carbon Roller", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, S, I, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 22, "Splat Roller", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, S, I, I, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 23, "Dynamo Roller", Rarity.Common, 5, new Space[,] { + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, S, I, I, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 24, "Flingza Roller", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, S, I, I, I, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 25, "Inkbrush", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, I, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, S, I, 0, 0, 0, 0, 0 }, + { 0, I, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 26, "Octobrush", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, I, 0 }, + { 0, 0, 0, 0, 0, I, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, S, I, 0, 0, 0, 0 }, + { 0, I, I, I, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 27, "Classic Squiffer", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, S, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 28, "Splat Charger", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, S, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 29, "Splatterscope", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, S, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 30, "E-liter 4K", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, S, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 31, "E-liter 4K Scope", Rarity.Common, 5, new Space[,] { + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, S, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 32, "Bamboozler 14 Mk I", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, S, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 33, "Goo Tuber", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, S, 0, 0, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 34, "Slosher", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, 0, 0, S, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 35, "Tri-Slosher", Rarity.Common, 2, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, S, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 36, "Sloshing Machine", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, S, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 37, "Bloblobber", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, S, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 38, "Explosher", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, S, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 39, "Mini Splatling", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, S, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 40, "Heavy Splatling", Rarity.Common, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, I, I, I, 0, 0, 0, 0 }, + { 0, I, I, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, S, I, 0 }, + { 0, 0, 0, 0, 0, 0, I, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 41, "Hydra Splatling", Rarity.Common, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, S, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 42, "Ballpoint Splatling", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, S, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 43, "Nautilus 47", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, S, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 44, "Dapple Dualies", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, 0, I, S, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 45, "Splat Dualies", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, S, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 46, "Glooga Dualies", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, 0, S, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 47, "Dualie Squelchers", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, S, I, 0, 0 }, + { 0, 0, I, I, 0, I, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 48, "Dark Tetra Dualies", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, 0, S, I, I, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 49, "Splat Brella", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, I, I, S, I, I, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 50, "Tenta Brella", Rarity.Common, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, I, 0, I, 0, I, 0, 0 }, + { 0, I, I, S, I, I, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 51, "Undercover Brella", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, I, 0, I, 0, I, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, 0, 0, S, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 52, "Tri-Stringer", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, I, I, I, I, I, 0, 0 }, + { 0, S, 0, I, 0, 0, 0, 0 }, + { 0, I, I, 0, 0, 0, 0, 0 }, + { 0, I, 0, 0, 0, 0, 0, 0 }, + { 0, I, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 53, "REEF-LUX 450", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, I, I, 0, 0 }, + { 0, 0, 0, S, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 54, "Splatana Stamper", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, S, I, I, I, I, I, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 55, "Splatana Wiper", Rarity.Common, 2, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, S, I, I, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 56, "Splat Bomb", Rarity.Common, 1, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, S, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 57, "Suction Bomb", Rarity.Common, 1, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, S, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 58, "Burst Bomb", Rarity.Common, 1, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, S, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 59, "Sprinkler", Rarity.Common, 1, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, S, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 60, "Splash Wall", Rarity.Common, 2, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, S, I, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 61, "Fizzy Bomb", Rarity.Common, 1, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, S, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 62, "Curling Bomb", Rarity.Common, 2, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, S, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 63, "Autobomb", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, S, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 64, "Squid Beakon", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, I, 0, 0 }, + { 0, 0, I, S, I, 0, 0, 0 }, + { 0, 0, I, I, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 65, "Point Sensor", Rarity.Common, 2, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, 0, 0, S, 0, 0, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 66, "Ink Mine", Rarity.Common, 2, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, I, 0, S, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 67, "Toxic Mist", Rarity.Common, 2, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, S, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 68, "Angle Shooter", Rarity.Common, 2, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, S, 0, 0, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 69, "Torpedo", Rarity.Common, 2, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, S, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 70, "Trizooka", Rarity.Rare, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, I, 0, 0 }, + { 0, 0, I, 0, I, I, I, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, I, I, 0, 0, 0, 0, 0 }, + { 0, I, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 71, "Big Bubbler", Rarity.Rare, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, 0, 0, I, 0, 0 }, + { 0, I, 0, 0, 0, 0, I, 0 }, + { 0, I, 0, 0, 0, 0, I, 0 }, + { 0, 0, I, 0, 0, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 72, "Zipcaster", Rarity.Rare, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, 0, 0, I, I, I, 0 }, + { 0, 0, 0, I, 0, I, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, I, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 73, "Tenta Missiles", Rarity.Rare, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 74, "Ink Storm", Rarity.Rare, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 75, "Booyah Bomb", Rarity.Rare, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 76, "Wave Breaker", Rarity.Rare, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, I, 0, I, 0, I, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, I, 0, 0, 0, I, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, I, 0, I, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 77, "Ink Vac", Rarity.Rare, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, I, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 78, "Killer Wail 5.1", Rarity.Rare, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, I, I, 0, 0, I, I, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, 0, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 79, "Inkjet", Rarity.Rare, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 80, "Ultra Stamp", Rarity.Rare, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 81, "Crab Tank", Rarity.Rare, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, I, 0, I, 0, I, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, I, 0, I, 0, I, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 82, "Reefslider", Rarity.Rare, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 83, "Triple Inkstrike", Rarity.Rare, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 84, "Tacticooler", Rarity.Rare, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, I, I, 0, I, 0, 0, 0 }, + { 0, I, 0, I, I, I, 0, 0 }, + { 0, I, I, 0, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 85, "Sheldon", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, 0, S, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, 0, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 86, "Gnarly Eddy", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, I, 0, I, 0, I, 0, 0 }, + { 0, I, I, I, I, I, 0, 0 }, + { 0, S, 0, I, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 87, "Jel La Fleur", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, I, I, 0, I, 0, 0 }, + { 0, I, I, I, S, 0, 0, 0 }, + { 0, 0, I, I, 0, I, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 88, "Mr. Coco", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, S, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, I, I, I, I, I, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 89, "Harmony", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, I, 0 }, + { 0, I, I, 0, I, I, 0, 0 }, + { 0, I, I, I, 0, 0, 0, 0 }, + { 0, I, I, S, 0, 0, 0, 0 }, + { 0, 0, I, 0, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, I, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 90, "Murch", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, I, 0, I, 0, I, 0, 0 }, + { 0, 0, I, 0, S, 0, 0, 0 }, + { 0, I, 0, I, 0, I, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 91, "Mr. Grizz", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, S, 0, I, I, 0, 0 }, + { 0, I, 0, I, I, I, I, 0 }, + { 0, 0, 0, 0, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, I, I, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 92, "Marigold", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, 0, I, I, 0, 0 }, + { 0, I, I, I, S, I, 0, 0 }, + { 0, 0, I, 0, I, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 93, "Smallfry", Rarity.Fresh, 1, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, S, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 94, "Cuttlefish", Rarity.Fresh, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, I, 0 }, + { 0, 0, I, 0, I, I, 0, 0 }, + { 0, I, I, I, I, I, S, 0 }, + { 0, 0, I, 0, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, I, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 95, "Captain", Rarity.Fresh, 6, new Space[,] { + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, I, I, I, I, 0, 0, 0 }, + { 0, I, I, S, 0, I, 0, 0 }, + { 0, I, I, I, 0, 0, I, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 96, "Callie", Rarity.Fresh, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, I, I, 0, 0, I, 0, 0 }, + { 0, I, I, I, 0, 0, I, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, I, S, 0, 0, I, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 97, "Marie", Rarity.Fresh, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, I, S, 0, 0, I, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, I, I, I, 0, 0, I, 0 }, + { 0, I, I, 0, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 98, "Shiver", Rarity.Fresh, 6, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, S, I, 0, 0, 0 }, + { 0, 0, I, 0, I, I, I, 0 }, + { I, I, I, I, 0, I, 0, 0 }, + { 0, 0, I, 0, I, I, I, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new( 99, "Frye", Rarity.Fresh, 6, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { I, I, 0, I, I, I, 0, 0 }, + { 0, I, I, 0, 0, 0, I, 0 }, + { I, I, 0, I, I, I, 0, 0 }, + { 0, 0, S, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(100, "Big Man", Rarity.Fresh, 6, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, I, 0, 0 }, + { 0, I, 0, I, I, I, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, I, 0 }, + { 0, I, I, 0, 0, S, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(101, "Judd", Rarity.Fresh, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, I, I, 0, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, 0, S, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(102, "Li'l Judd", Rarity.Fresh, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, S, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(103, "SquidForce", Rarity.Rare, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, I, I, I, I, I, 0, 0 }, + { 0, 0, 0, I, S, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(104, "Zink", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, I, 0, I, 0, I, 0, 0 }, + { 0, I, I, 0, S, 0, 0, 0 }, + { 0, I, I, I, 0, 0, 0, 0 }, + { 0, I, I, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(105, "Krak-On", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, S, I, I, I, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(106, "Rockenberg", Rarity.Rare, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, 0, S, 0, I, 0, 0 }, + { 0, 0, I, 0, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(107, "Zekko", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, S, I, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(108, "Forge", Rarity.Rare, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, S, I, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(109, "Firefin", Rarity.Rare, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, I, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, S, 0, I, I, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(110, "Skalop", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, S, I, I, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(111, "Splash Mob", Rarity.Rare, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, S, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(112, "Inkline", Rarity.Rare, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, S, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(113, "Tentatek", Rarity.Rare, 1, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, S, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(114, "Takoroka", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, S, I, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(115, "Annaki", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, I, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, I, I, I, S, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(116, "Enperry", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, I, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, 0, S, I, I, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, 0, I, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(117, "Toni Kensa", Rarity.Rare, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, 0, I, 0, S, 0, 0 }, + { 0, 0, 0, I, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(118, "Barazushi", Rarity.Rare, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, I, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, 0, I, 0, I, 0, 0 }, + { 0, 0, I, I, S, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(119, "Emberz", Rarity.Rare, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, I, 0, I, 0, I, 0, 0 }, + { 0, 0, 0, 0, S, 0, 0, 0 }, + { 0, I, 0, I, 0, I, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(120, "Octotrooper", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, I, S, I, I, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(121, "Shielded Octotrooper", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, I, S, I, I, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(122, "Twintacle Octotrooper", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, 0, I, S, I, I, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(123, "Octohopper", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, I, S, I, I, I, I, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(124, "Octocopter", Rarity.Common, 2, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, S, I, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(125, "Octobomber", Rarity.Common, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, S, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(126, "Octodisco", Rarity.Common, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, S, I, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, I, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(127, "Octopod", Rarity.Common, 1, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, S, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(128, "Oversized Octopod", Rarity.Rare, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, I, 0, 0 }, + { 0, 0, I, I, S, 0, 0, 0 }, + { 0, 0, I, I, 0, I, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(129, "Tentakook", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, 0, 0, S, I, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(130, "Octosniper", Rarity.Rare, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, I, I, I, I, I, 0, 0 }, + { 0, I, 0, S, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(131, "Octocommander", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, I, I, I, I, I, 0, 0 }, + { 0, I, 0, S, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(132, "Octomissile", Rarity.Common, 2, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, S, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(133, "Octozeppelin", Rarity.Rare, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, S, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(134, "Squee-G", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, I, 0, 0 }, + { 0, 0, I, I, S, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(135, "Octostamp", Rarity.Common, 2, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, S, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(136, "Amped Octostamp", Rarity.Rare, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, I, 0, S, 0, I, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(137, "Flooder", Rarity.Common, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, S, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(138, "Octoballer", Rarity.Common, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, I, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, I, I, I, S, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(139, "Octoling", Rarity.Rare, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, I, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, I, S, I, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(140, "DJ Octavio", Rarity.Fresh, 6, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, I, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, I, 0 }, + { 0, 0, I, I, S, I, 0, 0 }, + { 0, 0, 0, 0, I, 0, I, 0 }, + { 0, 0, 0, I, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(141, "Chum", Rarity.Common, 2, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, S, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(142, "Cohock", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, S, 0, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(143, "Snatcher", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, I, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, S, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(144, "Steelhead", Rarity.Common, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, 0, 0, 0, 0 }, + { 0, S, I, I, 0, 0, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(145, "Steel Eel", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, S, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(146, "Scrapper", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, S, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(147, "Stinger", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, S, 0, 0, 0, 0, 0, 0 }, + { 0, I, I, I, I, I, I, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(148, "Maws", Rarity.Common, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, I, I, S, 0, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(149, "Drizzler", Rarity.Common, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, S, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(150, "Flyfish", Rarity.Common, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, S, 0, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(151, "Fish Stick", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, I, 0, 0, 0, 0, 0, 0 }, + { 0, I, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, S, I, 0 }, + { 0, I, 0, 0, 0, 0, 0, 0 }, + { 0, I, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(152, "Flipper-Flopper", Rarity.Common, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, I, 0, I, I, S, 0, 0 }, + { 0, 0, I, I, I, I, I, 0 }, + { 0, I, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(153, "Slammin' Lid", Rarity.Common, 3, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, S, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(154, "Big Shot", Rarity.Common, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, S, I, 0, 0 }, + { 0, 0, 0, 0, I, I, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(155, "Goldie", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, 0, I, 0, 0, 0 }, + { 0, 0, I, I, I, I, 0, 0 }, + { 0, I, 0, 0, S, I, 0, 0 }, + { 0, 0, 0, I, 0, I, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(156, "Griller", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, S, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, I, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(157, "Mothership", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, I, I, S, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, I, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(158, "Mudmouth", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, I, I, I, I, I, 0 }, + { 0, I, 0, 0, 0, S, I, 0 }, + { 0, 0, I, I, I, I, I, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(159, "Zapfish", Rarity.Common, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, I, 0, 0 }, + { 0, 0, 0, I, I, 0, 0, 0 }, + { 0, 0, 0, I, S, I, 0, 0 }, + { 0, 0, I, 0, I, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(160, "Tower Control", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, I, I, S, I, I, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(161, "Rainmaker", Rarity.Rare, 5, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, I, 0, S, I, 0, 0, 0 }, + { 0, 0, 0, I, I, I, 0, 0 }, + { 0, 0, 0, I, 0, I, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + new(162, "Power Clam", Rarity.Rare, 4, new Space[,] { + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, S, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, I, I, I, 0, 0, 0 }, + { 0, 0, 0, I, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 }, + { 0, 0, 0, 0, 0, 0, 0, 0 } + }), + }; + + public static Version Version { get; } = new(1, 1, 0, 0); + public static string JSON { get; } + public static ReadOnlyCollection Cards { get; } + + static CardDatabase() { + Cards = Array.AsReadOnly(cards); + JSON = JsonConvert.SerializeObject(cards); + } + + public static Card GetCard(int number) { + number--; + return number >= 0 && number < cards.Length ? cards[number] : throw new ArgumentOutOfRangeException(nameof(number)); + } +} diff --git a/TableturfBattleServer/Colour.cs b/TableturfBattleServer/Colour.cs new file mode 100644 index 0000000..dac809c --- /dev/null +++ b/TableturfBattleServer/Colour.cs @@ -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; + } +} diff --git a/TableturfBattleServer/Error.cs b/TableturfBattleServer/Error.cs new file mode 100644 index 0000000..9ef7c04 --- /dev/null +++ b/TableturfBattleServer/Error.cs @@ -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)); + } +} diff --git a/TableturfBattleServer/Game.cs b/TableturfBattleServer/Game.cs new file mode 100644 index 0000000..5b6c4da --- /dev/null +++ b/TableturfBattleServer/Game.cs @@ -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 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(); + var specialSpacesActivated = new List(); + + // 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 })); + } + } + } + } +} diff --git a/TableturfBattleServer/GameState.cs b/TableturfBattleServer/GameState.cs new file mode 100644 index 0000000..4210247 --- /dev/null +++ b/TableturfBattleServer/GameState.cs @@ -0,0 +1,9 @@ +namespace TableturfBattleServer; + +public enum GameState { + WaitingForPlayers, + Preparing, + Redraw, + Ongoing, + Ended +} \ No newline at end of file diff --git a/TableturfBattleServer/Move.cs b/TableturfBattleServer/Move.cs new file mode 100644 index 0000000..90f4b7f --- /dev/null +++ b/TableturfBattleServer/Move.cs @@ -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; +} diff --git a/TableturfBattleServer/Placement.cs b/TableturfBattleServer/Placement.cs new file mode 100644 index 0000000..4b1a929 --- /dev/null +++ b/TableturfBattleServer/Placement.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace TableturfBattleServer; +public class Placement { + [JsonProperty("players")] + public List Players { get; } = new(); + [JsonProperty("spacesAffected"), JsonConverter(typeof(SpacesAffectedDictionaryConverter))] + public Dictionary SpacesAffected { get; } = new(); + + internal class SpacesAffectedDictionaryConverter : JsonConverter> { + public override Dictionary? ReadJson(JsonReader reader, Type objectType, Dictionary? existingValue, bool hasExistingValue, JsonSerializer serializer) { + var list = serializer.Deserialize>(reader); + return list?.ToDictionary(o => o.space, o => o.newState); + } + + public override void WriteJson(JsonWriter writer, Dictionary? value, JsonSerializer serializer) { + serializer.Serialize(writer, value?.Select(e => new { space = e.Key, newState = e.Value })); + } + } +} diff --git a/TableturfBattleServer/Player.cs b/TableturfBattleServer/Player.cs new file mode 100644 index 0000000..4f00428 --- /dev/null +++ b/TableturfBattleServer/Player.cs @@ -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 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; + } +} diff --git a/TableturfBattleServer/Point.cs b/TableturfBattleServer/Point.cs new file mode 100644 index 0000000..30b7ea9 --- /dev/null +++ b/TableturfBattleServer/Point.cs @@ -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; + } +} diff --git a/TableturfBattleServer/Program.cs b/TableturfBattleServer/Program.cs new file mode 100644 index 0000000..8cc7693 --- /dev/null +++ b/TableturfBattleServer/Program.cs @@ -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 games = new(); + internal static readonly Timer timer = new(500); + private static readonly List gameIdsToRemove = new(); + + internal static void Main() { + timer.Elapsed += Timer_Elapsed; + + httpServer.AddWebSocketService("/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 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(); + } +} diff --git a/TableturfBattleServer/Rarity.cs b/TableturfBattleServer/Rarity.cs new file mode 100644 index 0000000..7eee945 --- /dev/null +++ b/TableturfBattleServer/Rarity.cs @@ -0,0 +1,7 @@ +namespace TableturfBattleServer; + +public enum Rarity { + Common, + Rare, + Fresh +} diff --git a/TableturfBattleServer/Space.cs b/TableturfBattleServer/Space.cs new file mode 100644 index 0000000..3630ba8 --- /dev/null +++ b/TableturfBattleServer/Space.cs @@ -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 +} diff --git a/TableturfBattleServer/TableturfBattleServer.csproj b/TableturfBattleServer/TableturfBattleServer.csproj new file mode 100644 index 0000000..0ee4307 --- /dev/null +++ b/TableturfBattleServer/TableturfBattleServer.csproj @@ -0,0 +1,18 @@ + + + + Exe + net6.0 + enable + enable + https://github.com/AndrioCelos/TableturfBattleApp + https://github.com/AndrioCelos/TableturfBattleApp + 0.0.0.0 + + + + + + + + diff --git a/TableturfBattleServer/TableturfWebSocketBehaviour.cs b/TableturfBattleServer/TableturfWebSocketBehaviour.cs new file mode 100644 index 0000000..c573ccc --- /dev/null +++ b/TableturfBattleServer/TableturfWebSocketBehaviour.cs @@ -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); +} \ No newline at end of file diff --git a/license.md b/license.md new file mode 100644 index 0000000..8b00290 --- /dev/null +++ b/license.md @@ -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. diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..f810e7b --- /dev/null +++ b/readme.md @@ -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.